diff --git a/README.md b/README.md index 31466b54c..706cdcd22 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,35 @@ -# Final Project +# MiniStops -Replace this readme with your own information about your project. +A web application that helps parents quickly find baby-changing facilities nearby. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +## View it live +- Frontend: https://project-final-irisdgz-1.onrender.com +- Backend API: https://project-final-irisdgz.onrender.com ## The problem +As a parent of a toddler, finding a clean and accessible place to change a diaper can be tricky. MiniStops makes this easier by showing nearby changing facilities and allowing parents to share information about them. -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +## Features +Users can: +- Sign up and log in +- Add new places with features and a map-picked location +- Filter places by city, category, or amenities +- View all places on an interactive map +- Read and leave reviews with ratings -## View it live +## Tech stack +**Frontend:** React, React Router, Zustand, Styled-components, React Leaflet + +**Backend:** Node.js, Express, MongoDB with Mongoose, JWT authentication + +## How I built it +I started by defining the core features (authentication, map, reviews, adding places), built the backend API first, then connected the frontend to it. + +One interesting challenge was using React Leaflet's `useMapEvents` hook to let users click directly on the map to pick a location for a new place. -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +## If I had more time +- Allow browsing and viewing places without needing to log in +- Show the user's current location on the map +- Allow editing and deleting reviews +- Add photos for places +- Push notifications for new reviews \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..e69de29bb diff --git a/backend/package.json b/backend/package.json index 08f29f244..4de7ad74b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,19 +2,19 @@ "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" + "start": "node src/server.js", + "dev": "nodemon src/server.js" }, - "author": "", - "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.17.3", + "express-list-endpoints": "^7.1.0", + "jsonwebtoken": "^9.0.3", "mongoose": "^8.4.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 070c87518..000000000 --- a/backend/server.js +++ /dev/null @@ -1,22 +0,0 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; - -const port = process.env.PORT || 8080; -const app = express(); - -app.use(cors()); -app.use(express.json()); - -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 000000000..cf55ae97a --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,24 @@ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import listEndpoints from "express-list-endpoints"; + +import routes from "./routes/index.js"; +import { errorHandler } from "./middleware/errorHandler.js"; + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +app.get("/", (req, res) => { + res.json({ + message: "Baby Changing Places API", + endpoints: listEndpoints(app), + }); +}); + +app.use(routes); +app.use(errorHandler); + +export default app; \ No newline at end of file diff --git a/backend/src/db/connectDB.js b/backend/src/db/connectDB.js new file mode 100644 index 000000000..fd984fb4d --- /dev/null +++ b/backend/src/db/connectDB.js @@ -0,0 +1,13 @@ +import mongoose from "mongoose"; + +export const connectDB = async () => { + const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/babyplaces"; + + try { + await mongoose.connect(mongoUrl); + console.log("✅ Connected to MongoDB"); + } catch (err) { + console.error("❌ MongoDB connection error:", err.message); + process.exit(1); + } +}; \ No newline at end of file diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 000000000..648172823 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,29 @@ +import jwt from "jsonwebtoken"; + +export const authenticateUser = (req, res, next) => { + try { + const header = req.headers.authorization || ""; + const [type, token] = header.split(" "); + + if (type !== "Bearer" || !token) { + return res.status(401).json({ + success: false, + message: "Missing or invalid Authorization header", + }); + } + + if (!process.env.JWT_SECRET) { + return res.status(500).json({ + success: false, + message: "Server misconfigured (JWT_SECRET missing)", + }); + } + + const payload = jwt.verify(token, process.env.JWT_SECRET); + req.user = payload; + + next(); + } catch (err) { + return res.status(401).json({ success: false, message: "Invalid or expired token" }); + } +}; \ 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 000000000..bf9ae1934 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,9 @@ +export const errorHandler = (err, req, res, next) => { + console.error(err); + + const status = err.status || 500; + res.status(status).json({ + success: false, + message: err.message || "Something went wrong", + }); +}; \ No newline at end of file diff --git a/backend/src/models/Place.js b/backend/src/models/Place.js new file mode 100644 index 000000000..967e332a7 --- /dev/null +++ b/backend/src/models/Place.js @@ -0,0 +1,58 @@ +import mongoose from "mongoose"; + +const placeSchema = new mongoose.Schema( + { + name: { type: String, required: true, trim: true, minlength: 2 }, + + category: { + type: String, + enum: ["cafe", "restaurant", "mall", "public", "other"], + default: "other", + }, + + address: { type: String, trim: true }, + + city: { type: String, required: true, trim: true }, + + + location: { + type: { + type: String, + enum: ["Point"], + default: "Point", + required: true, + }, + coordinates: { + type: [Number], + required: true, + validate: { + validator: (arr) => + Array.isArray(arr) && + arr.length === 2 && + arr.every((n) => Number.isFinite(n)), + message: "location.coordinates must be [lng, lat] (two numbers)", + }, + }, + }, + + features: { + changingTable: { type: Boolean, default: true }, + babyLounge: { type: Boolean, default: false }, + strollerAccess: { type: Boolean, default: false }, + accessible: { type: Boolean, default: false }, + disposableMats: { type: Boolean, default: false }, + diaperBags: { type: Boolean, default: false }, + clean: { type: Boolean, default: false }, + }, + + avgRating: { type: Number, default: 0, min: 0, max: 5 }, + reviewCount: { type: Number, default: 0, min: 0 }, + + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, + }, + { timestamps: true } +); + +placeSchema.index({ location: "2dsphere" }); + +export const Place = mongoose.model("Place", placeSchema); \ No newline at end of file diff --git a/backend/src/models/Review.js b/backend/src/models/Review.js new file mode 100644 index 000000000..d4e01529a --- /dev/null +++ b/backend/src/models/Review.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; + +const reviewSchema = new mongoose.Schema( + { + placeId: { type: mongoose.Schema.Types.ObjectId, ref: "Place", required: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + rating: { type: Number, required: true, min: 1, max: 5 }, + comment: { type: String, trim: true, maxlength: 500 }, + }, + { timestamps: true } +); + + +reviewSchema.index({ placeId: 1, userId: 1 }, { unique: true }); + +export const Review = mongoose.model("Review", reviewSchema); \ No newline at end of file diff --git a/backend/src/models/User.js b/backend/src/models/User.js new file mode 100644 index 000000000..3d3d676f9 --- /dev/null +++ b/backend/src/models/User.js @@ -0,0 +1,30 @@ +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + match: /.+\@.+\..+/, + }, + + username: { + type: String, + trim: true, + maxlength: 30, + }, + + passwordHash: { + type: String, + required: true, + }, + }, + { timestamps: true } +); + +userSchema.index({ email: 1 }, { unique: true }); + +export const User = mongoose.model("User", userSchema); \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 000000000..235277529 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,99 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +router.post("/signup", async (req, res, next) => { + try { + const { email, password, username } = req.body; + const normalizedEmail = email?.toLowerCase().trim(); + + if (!normalizedEmail || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required", + }); + } + + if (password.length < 6) { + return res.status(400).json({ + success: false, + message: "Password must be at least 6 characters", + }); + } + + const existing = await User.findOne({ email: normalizedEmail }); + + if (existing) { + return res.status(409).json({ + success: false, + message: "Email already in use", + }); + } + + const passwordHash = await bcrypt.hash(password, 12); + + const user = await User.create({ + email: normalizedEmail, + username, + passwordHash, + }); + + const accessToken = jwt.sign( + { userId: user._id, email: user.email }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ); + + res.status(201).json({ + success: true, + accessToken, + user: { + userId: user._id, + email: user.email, + username: user.username || "", + }, + }); + } catch (err) { + next(err); + } +}); + +router.post("/login", async (req, res, next) => { + try { + const { email, password } = req.body; + const user = await User.findOne({ email: email?.toLowerCase() }); + + if (!user) { + return res.status(401).json({ success: false, message: "Invalid credentials" }); + } + + const ok = await bcrypt.compare(password, user.passwordHash); + + if (!ok) { + return res.status(401).json({ success: false, message: "Invalid credentials" }); + } + + const accessToken = jwt.sign( + { userId: user._id, email: user.email }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ); + + res.json({ + success: true, + accessToken, + user: { + userId: user._id, + email: user.email, + username: user.username || "", + }, + }); + } catch (err) { + next(err); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 000000000..b62b93d99 --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,12 @@ +import express from "express"; +import authRoutes from "./auth.js"; +import placesRoutes from "./places.js"; +import reviewsRoutes from "./reviews.js"; + +const router = express.Router(); + +router.use("/auth", authRoutes); +router.use("/places", placesRoutes); +router.use("/", reviewsRoutes); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/places.js b/backend/src/routes/places.js new file mode 100644 index 000000000..0334477f5 --- /dev/null +++ b/backend/src/routes/places.js @@ -0,0 +1,75 @@ +import express from "express"; +import { Place } from "../models/Place.js"; +import { authenticateUser } from "../middleware/auth.js"; + +const router = express.Router(); + +// GET all places DEMO +router.get("/", async (req, res, next) => { + try { + const places = await Place.find().sort({ createdAt: -1 }); + res.json({ success: true, places }); + } catch (err) { + next(err); + } +}); +router.get("/:id", async (req, res, next) => { + try { + const place = await Place.findById(req.params.id); + + if (!place) { + return res.status(404).json({ success: false, message: "Place not found" }); + } + + res.json({ success: true, place }); + } catch (err) { + next(err); + } +}); + + +router.post("/", authenticateUser, async (req, res, next) => { + try { + const { name, category, address, city, lat, lng, features } = req.body; + + if (!name || !city || lat == null || lng == null) { + return res.status(400).json({ + success: false, + message: "name, city, lat, lng are required", + }); + } + + const latNum = Number(lat); + const lngNum = Number(lng); + + const validCoords = + Number.isFinite(latNum) && + Number.isFinite(lngNum) && + latNum >= -90 && + latNum <= 90 && + lngNum >= -180 && + lngNum <= 180; + + if (!validCoords) { + return res.status(400).json({ + success: false, + message: "lat must be -90..90 and lng must be -180..180", + }); + } + + const newPlace = await Place.create({ + name, + category, + address, + city, + features: features || {}, + location: { type: "Point", coordinates: [lngNum, latNum] }, + }); + + res.status(201).json({ success: true, place: newPlace }); + } catch (err) { + next(err); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/reviews.js b/backend/src/routes/reviews.js new file mode 100644 index 000000000..01c3d6586 --- /dev/null +++ b/backend/src/routes/reviews.js @@ -0,0 +1,45 @@ +import express from "express"; +import mongoose from "mongoose"; +import { Review } from "../models/Review.js"; +import { authenticateUser } from "../middleware/auth.js"; +import { recalcRatingForPlace } from "../utils/recalcRating.js"; + +const router = express.Router(); + +// GET /places/:id/reviews +router.get("/places/:id/reviews", async (req, res, next) => { + try { + const placeId = new mongoose.Types.ObjectId(req.params.id); + const reviews = await Review.find({ placeId }).sort({ createdAt: -1 }).limit(50); + res.json({ success: true, reviews }); + } catch (err) { + next(err); + } +}); + +// POST /places/:id/reviews (protected) +router.post("/places/:id/reviews", authenticateUser, async (req, res, next) => { + try { + const placeId = new mongoose.Types.ObjectId(req.params.id); + const { rating, comment } = req.body; + + const review = await Review.create({ + placeId, + userId: req.user.userId, + rating, + comment, + }); + + await recalcRatingForPlace(placeId); + + res.status(201).json({ success: true, review }); + } catch (err) { + // handle unique index (one review per user per place) + if (err.code === 11000) { + return res.status(409).json({ success: false, message: "You already reviewed this place" }); + } + next(err); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 000000000..c571957fc --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,14 @@ +import app from "./app.js"; +import { connectDB } from "./db/connectDB.js"; + +const port = process.env.PORT || 8081; + +const startServer = async () => { + await connectDB(); + + app.listen(port, () => { + console.log(`✅ Server running on port ${port}`); + }); +}; + +startServer(); \ No newline at end of file diff --git a/backend/src/utils/recalcRating.js b/backend/src/utils/recalcRating.js new file mode 100644 index 000000000..71f89e022 --- /dev/null +++ b/backend/src/utils/recalcRating.js @@ -0,0 +1,23 @@ +import { Review } from "../models/Review.js"; +import { Place } from "../models/Place.js"; + +export const recalcRatingForPlace = async (placeId) => { + const agg = await Review.aggregate([ + { $match: { placeId } }, + { + $group: { + _id: "$placeId", + avgRating: { $avg: "$rating" }, + reviewCount: { $sum: 1 }, + }, + }, + ]); + + const avgRating = agg[0]?.avgRating || 0; + const reviewCount = agg[0]?.reviewCount || 0; + + await Place.findByIdAndUpdate(placeId, { + avgRating: Math.round(avgRating * 10) / 10, + reviewCount, + }); +}; \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..a038a88a9 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=https://project-final-irisdgz.onrender.com \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..8973a5063 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,4 +5,55 @@ This boilerplate is designed to give you a head start in your React projects, wi ## Getting Started 1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +2. Start the development server using `npm run dev`. + +Baby Changing Places — Full-Stack App + +A full-stack web application for finding and reviewing baby changing facilities in public places. +Users can browse locations, see details and reviews, and (when logged in) add new places or leave a review. + +This project was built as a final bootcamp project using a React frontend and a Node/Express/MongoDB backend. + + +Features + +* View a list of baby changing places +* View detailed information for each place +* See reviews and average ratings +* Create an account and log in +* Add new places (authenticated) +* Add one review per place (authenticated) +* Prevent duplicate reviews from the same user + + +Tech Stack + +Frontend + +Built using React + +Navigation handled with React Router + +Global state managed using Context API / Zustand (depending on implementation) + +Backend + +Node.js with Express + +MongoDB database with Mongoose models + +Authentication + +JWT-based authentication implemented + +Protected routes require valid token + +External Libraries + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..b584a77be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,13 @@
+ -