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 @@ + - Technigo React Vite Boiler Plate + + MiniStops
- + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..0f9a6a5d0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-router-dom": "^7.13.1", + "styled-components": "^6.3.11", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..cc1617d0f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,23 @@ -export const App = () => { +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Navbar from "./components/Navbar"; +import Home from "./pages/Home"; +import PlaceDetails from "./pages/PlaceDetails"; +import Login from "./pages/Login"; +import Signup from "./pages/Signup"; +import AddPlace from "./pages/AddPlace"; +import ProtectedRoute from "./components/ProtectedRoute"; +export const App = () => { return ( - <> -

Welcome to Final Project!

- + + + + } /> + } /> + } /> + } /> + } /> + + ); -}; +}; \ No newline at end of file diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 000000000..a6ff6705e --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,35 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL; + +export async function signup(email, password) { + const res = await fetch(`${API_BASE_URL}/auth/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Signup failed"); + } + + localStorage.setItem("token", data.accessToken); + return data; +} + +export async function login(email, password) { + const res = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Login failed"); + } + + localStorage.setItem("token", data.accessToken); + return data; +} \ No newline at end of file diff --git a/frontend/src/api/places.js b/frontend/src/api/places.js new file mode 100644 index 000000000..6b5a1ff98 --- /dev/null +++ b/frontend/src/api/places.js @@ -0,0 +1,44 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL; + +export async function getPlaces() { + const res = await fetch(`${API_BASE_URL}/places`); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Failed to fetch places"); + } + + return data; +} + +export async function getPlace(id) { + const res = await fetch(`${API_BASE_URL}/places/${id}`); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Failed to fetch place"); + } + + return data; +} + +export async function createPlace(place) { + const token = localStorage.getItem("token"); + + const res = await fetch(`${API_BASE_URL}/places`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(place), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || "Failed to create place"); + } + + return data; +} \ No newline at end of file diff --git a/frontend/src/api/reviews.js b/frontend/src/api/reviews.js new file mode 100644 index 000000000..d851c388e --- /dev/null +++ b/frontend/src/api/reviews.js @@ -0,0 +1,23 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL; + +export async function getReviews(placeId) { + const res = await fetch(`${API_BASE_URL}/places/${placeId}/reviews`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.message || "Could not fetch reviews"); + return data; +} + +export async function createReview(placeId, { rating, comment }, accessToken) { + const res = await fetch(`${API_BASE_URL}/places/${placeId}/reviews`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ rating, comment }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data?.message || "Could not create review"); + return data; +} \ No newline at end of file diff --git a/frontend/src/components/Filters.jsx b/frontend/src/components/Filters.jsx new file mode 100644 index 000000000..79c09fe19 --- /dev/null +++ b/frontend/src/components/Filters.jsx @@ -0,0 +1,116 @@ +import styled from "styled-components"; + +export default function Filters({ value, onChange }) { + const { city = "", category = "", onlyChangingTable = false } = value || {}; + + function update(next) { + onChange({ ...(value || {}), ...next }); + } + + return ( + + + + update({ city: e.target.value })} + placeholder="e.g. Solna" + autoComplete="address-level2" + /> + + + + + + + + + update({ onlyChangingTable: e.target.checked })} + /> + + Only show places with changing table + + + + ); +} + +/* styled components */ + +const Wrap = styled.section` + border: 1px solid #e7e7e7; + border-radius: 16px; + padding: 20px; + display: grid; + gap: 16px; + background: white; +`; + +const Field = styled.div` + display: grid; + gap: 6px; +`; + +const Label = styled.label` + font-size: 13px; + color: #666; +`; + +const Input = styled.input` + border: 1px solid #e7e7e7; + border-radius: 12px; + padding: 12px 14px; + font-size: 15px; + + &:focus { + outline: none; + border-color: #bbb; + } +`; + +const Select = styled.select` + border: 1px solid #e7e7e7; + border-radius: 12px; + padding: 12px 14px; + font-size: 15px; + background: white; + + &:focus { + outline: none; + border-color: #bbb; + } +`; + +const CheckRow = styled.div` + display: flex; + gap: 10px; + align-items: center; + padding: 12px; + border-radius: 12px; + background: #f7f7f7; +`; + +const Checkbox = styled.input` + width: 16px; + height: 16px; +`; + +const CheckLabel = styled.label` + font-size: 14px; + color: #111; +`; \ No newline at end of file diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 000000000..a11eeb437 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,118 @@ +import { Link, NavLink } from "react-router-dom"; +import styled from "styled-components"; +import { useAuthStore } from "../store/authStore"; + +export default function Navbar() { + const isLoggedIn = useAuthStore((state) => state.isLoggedIn); + const logout = useAuthStore((state) => state.logout); + + return ( +
+ MiniStops + + +
+ ); +} + +const Header = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 40px; + border-bottom: 1px solid #eee; + + @media (max-width: 768px) { + padding: 18px 20px; + } + + @media (max-width: 480px) { + padding: 14px 12px; + } +`; + +const Brand = styled(Link)` + font-size: 50px; + font-weight: 800; + text-decoration: none; + color: black; + letter-spacing: -1px; + line-height: 1; + + &:hover { + text-decoration: none; + } + + @media (max-width: 768px) { + font-size: 40px; + } + + @media (max-width: 480px) { + font-size: 28px; + } +`; + +const Nav = styled.nav` + display: flex; + gap: 18px; + align-items: center; + + @media (max-width: 768px) { + gap: 12px; + } + + @media (max-width: 480px) { + gap: 8px; + } +`; + +const NavItem = styled(NavLink)` + text-decoration: none; + color: var(--text); + font-size: 16px; + font-weight: 500; + padding: 4px 6px; + border-radius: 6px; + transition: color 0.2s ease; + + &:hover { + color: var(--muted); + } + + &.active { + font-weight: 700; + } + + @media (max-width: 480px) { + font-size: 14px; + padding: 2px 4px; + } +`; + +const LogoutButton = styled.button` + background: none; + border: none; + padding: 4px 6px; + cursor: pointer; + font-size: 16px; + font-weight: 500; + color: var(--text); + transition: color 0.2s ease; + + &:hover { + color: var(--muted); + } + + @media (max-width: 480px) { + font-size: 14px; + padding: 2px 4px; + } +`; \ No newline at end of file diff --git a/frontend/src/components/PlaceCard.jsx b/frontend/src/components/PlaceCard.jsx new file mode 100644 index 000000000..e3e2bca7b --- /dev/null +++ b/frontend/src/components/PlaceCard.jsx @@ -0,0 +1,121 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +export default function PlaceCard({ place }) { + const { + _id, + name, + city, + category, + avgRating, + reviewCount, + features = {}, + } = place; + + return ( + + + + <StyledLink to={`/places/${_id}`}>{name}</StyledLink> + + + {category && {category}} + + + {city} + + + ⭐ {avgRating ?? 0} + ({reviewCount ?? 0}) + + + + {features.changingTable && Changing table} + {features.babyLounge && Baby lounge} + {features.strollerAccess && Stroller friendly} + {features.accessible && Wheelchair access} + {features.disposableMats && Disposable mats} + {features.diaperBags && Diaper bags} + {features.clean && Very clean} + + + ); +} + +/* styled components */ + +const Card = styled.article` + border: 1px solid #e7e7e7; + border-radius: 16px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; + background: white; + transition: transform 0.15s ease, border-color 0.15s ease; + + &:hover { + transform: translateY(-3px); + border-color: #cfcfcf; + } +`; + +const Top = styled.div` + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +`; + +const Title = styled.h3` + margin: 0; + font-size: 18px; + line-height: 1.2; +`; + +const StyledLink = styled(Link)` + text-decoration: none; + color: #111; + + &:hover { + text-decoration: underline; + } +`; + +const Pill = styled.span` + border: 1px solid #e7e7e7; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: #666; + white-space: nowrap; +`; + +const City = styled.p` + margin: 0; + color: #555; +`; + +const Rating = styled.div` + font-size: 14px; + font-weight: 600; + + span { + font-weight: 400; + color: #777; + margin-left: 4px; + } +`; + +const Features = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`; + +const Feature = styled.span` + background: #f6f6f6; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; +`; \ No newline at end of file diff --git a/frontend/src/components/PlacesMap.jsx b/frontend/src/components/PlacesMap.jsx new file mode 100644 index 000000000..d8ad90078 --- /dev/null +++ b/frontend/src/components/PlacesMap.jsx @@ -0,0 +1,39 @@ +import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"; +import { Link } from "react-router-dom"; + +export default function PlacesMap({ places }) { + const defaultCenter = [59.3293, 18.0686]; + return ( + + + {places.map((place) => { + const coordinates = place.location?.coordinates; + if (!coordinates || coordinates.length < 2) return null; + const [lng, lat] = coordinates; + return ( + + + {place.name} +
+ {place.city} +
+ View details +
+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 000000000..4679db65b --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,11 @@ +import { Navigate } from "react-router-dom"; +import { useAuthStore } from "../store/authStore"; + +export default function ProtectedRoute({ children }) { + const accessToken = useAuthStore((s) => s.accessToken); + if (!accessToken) { + return ; + } + + return children; +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..772895a46 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,26 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App.jsx"; +import { GlobalStyles } from "./styles/GlobalStyles"; import "./index.css"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; + +import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png"; +import markerIcon from "leaflet/dist/images/marker-icon.png"; +import markerShadow from "leaflet/dist/images/marker-shadow.png"; + +delete L.Icon.Default.prototype._getIconUrl; + +L.Icon.Default.mergeOptions({ + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, +}); ReactDOM.createRoot(document.getElementById("root")).render( + -); +); \ No newline at end of file diff --git a/frontend/src/pages/AddPlace.jsx b/frontend/src/pages/AddPlace.jsx new file mode 100644 index 000000000..faae9439c --- /dev/null +++ b/frontend/src/pages/AddPlace.jsx @@ -0,0 +1,386 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet"; +import { useAuthStore } from "../store/authStore"; + +const API_BASE_URL = import.meta.env.VITE_API_URL; + +function LocationPicker({ lat, lng, setLat, setLng }) { + useMapEvents({ + click(event) { + const clickedLat = event.latlng.lat; + const clickedLng = event.latlng.lng; + + setLat(clickedLat.toFixed(6)); + setLng(clickedLng.toFixed(6)); + }, + }); + + if (!lat || !lng) return null; + + return ; +} + +export default function AddPlace() { + const navigate = useNavigate(); + const accessToken = useAuthStore((state) => state.accessToken); + + const [name, setName] = useState(""); + const [city, setCity] = useState(""); + const [category, setCategory] = useState("other"); + const [lat, setLat] = useState(""); + const [lng, setLng] = useState(""); + const [error, setError] = useState(""); + + const [features, setFeatures] = useState({ + changingTable: false, + babyLounge: false, + strollerAccess: false, + accessible: false, + disposableMats: false, + diaperBags: false, + clean: false, + }); + + const handleFeatureChange = (event) => { + const { name, checked } = event.target; + + setFeatures((prevFeatures) => ({ + ...prevFeatures, + [name]: checked, + })); + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + setError(""); + + if (!accessToken) { + setError("You need to log in first."); + return; + } + + const latNumber = Number(lat); + const lngNumber = Number(lng); + + if (!name.trim() || !city.trim()) { + setError("Please fill in name and city."); + return; + } + + if (isNaN(latNumber) || isNaN(lngNumber)) { + setError("Please choose a location on the map."); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/places`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + name: name.trim(), + city: city.trim(), + category, + lat: latNumber, + lng: lngNumber, + features, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || "Could not add place."); + return; + } + + navigate("/"); + } catch { + setError("Something went wrong. Please try again."); + } + }; + + const mapCenter = + lat && lng ? [Number(lat), Number(lng)] : [59.3293, 18.0686]; + + return ( + + Add a new place + +
+ + Name * + setName(event.target.value)} + /> + + + + City * + setCity(event.target.value)} + /> + + + + Category + + + + + Pick the location on the map * + + + + + + + + + + Lat * + setLat(event.target.value)} + placeholder="59.334" + /> + + + + Lng * + setLng(event.target.value)} + placeholder="18.063" + /> + + + + + Features + + + Changing table available + + + + + Baby lounge available + + + + + Stroller friendly + + + + + Wheelchair accessible + + + + + Disposable changing mats available + + + + + Diaper disposal bags available + + + + + Cleanliness + + + + + {error && {error}} + + +
+
+ ); +} + +const Wrapper = styled.main` + max-width: 700px; + margin: 0 auto; + padding: 40px 16px; + + @media (max-width: 600px) { + padding: 24px 12px; + } +`; + +const Title = styled.h1` + margin-bottom: 24px; + font-size: 32px; + font-weight: 700; + line-height: 1.1; + + @media (max-width: 600px) { + margin-bottom: 20px; + font-size: 28px; + } +`; + +const Form = styled.form` + display: grid; + gap: 16px; + + input[type="text"], + select { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #ddd; + width: 100%; + box-sizing: border-box; + } + + button { + padding: 10px; + border-radius: 8px; + border: 1px solid #ddd; + background: white; + cursor: pointer; + } +`; + +const FieldLabel = styled.label` + display: grid; + gap: 6px; + font-size: 14px; +`; + +const MapSection = styled.div` + display: grid; + gap: 8px; +`; + +const MapText = styled.p` + margin: 0; + font-size: 14px; +`; + +const Row = styled.div` + display: grid; + gap: 12px; + grid-template-columns: 1fr 1fr; + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } +`; + +const FeatureSection = styled.div` + display: grid; + gap: 10px; +`; + +const FeatureHeading = styled.h2` + margin: 0; + font-size: 18px; + + @media (max-width: 600px) { + font-size: 16px; + } +`; + +const CheckboxLabel = styled.label` + display: grid; + grid-template-columns: 1fr 24px; + align-items: center; + column-gap: 12px; + width: 100%; + max-width: 420px; + font-size: 15px; + cursor: pointer; + padding: 4px 0; + + span { + line-height: 1.4; + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; + } + + @media (max-width: 600px) { + max-width: 100%; + font-size: 14px; + } +`; + +const Error = styled.p` + color: red; + margin: 0; +`; \ No newline at end of file diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 000000000..3bedd3075 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import { getPlaces } from "../api/places"; +import PlaceCard from "../components/PlaceCard"; +import PlacesMap from "../components/PlacesMap"; +import Filters from "../components/Filters"; + +export default function Home() { + const [places, setPlaces] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState({ + city: "", + category: "", + onlyChangingTable: false, + }); + + useEffect(() => { + setLoading(true); + setError(""); + getPlaces() + .then((data) => setPlaces(data.places || [])) + .catch((err) => setError(err.message || "Failed to load places")) + .finally(() => setLoading(false)); + }, []); + + const filtered = places.filter((p) => { + if (filters.city && !p.city.toLowerCase().includes(filters.city.toLowerCase())) return false; + if (filters.category && p.category !== filters.category) return false; + if (filters.onlyChangingTable && !p.features?.changingTable) return false; + return true; + }); + + return ( + + + Time for a MiniStop? + Find a clean changing spot, quick and easy. + + + + + + + + +
+ {loading && Loading places...} + {error && {error}} + {!loading && !error && filtered.length === 0 && ( + No places match your filters. + )} + + {filtered.map((p) => ( + + ))} + +
+
+
+ ); +} + +const Wrapper = styled.main` + max-width: 1400px; + margin: 0 auto; + padding: 40px 40px 56px; + @media (max-width: 768px) { padding: 32px 20px 48px; } + @media (max-width: 480px) { padding: 24px 14px 40px; } +`; +const Hero = styled.section`margin-bottom: 32px;`; +const Title = styled.h1` + margin: 0 0 10px; + font-size: 30px; + font-weight: 600; +`; +const Subtitle = styled.p` + margin: 0; + font-size: 18px; + color: #666; +`; +const MapWrapper = styled.div` + margin-bottom: 32px; + border-radius: 16px; + overflow: hidden; +`; +const ContentGrid = styled.div` + display: grid; + grid-template-columns: 260px 1fr; + gap: 24px; + align-items: start; + @media (max-width: 768px) { grid-template-columns: 1fr; } +`; +const Sidebar = styled.aside``; +const Section = styled.section`display: grid; gap: 16px;`; +const Grid = styled.div` + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + @media (max-width: 480px) { grid-template-columns: 1fr; } +`; +const StatusText = styled.p`margin: 0; color: #444;`; +const ErrorText = styled.p`margin: 0; color: red;`; \ No newline at end of file diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 000000000..5d7dc403c --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import { useAuthStore } from "../store/authStore"; + +const API_BASE_URL = import.meta.env.VITE_API_URL; + +export default function Login() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + + const onSubmit = async (e) => { + e.preventDefault(); + setErrorMsg(""); + + try { + const res = await fetch(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setErrorMsg(data?.message || "Could not log in"); + return; + } + + login({ accessToken: data.accessToken, user: data.user }); + navigate("/"); + } catch { + setErrorMsg("Network error. Try again."); + } + }; + + return ( + + + Log in + +
+ + + + + {errorMsg && {errorMsg}} + + +
+ +

+ No account yet? Sign up here +

+
+
+ ); +} + +const Page = styled.main` + padding: 24px 16px; + display: flex; + justify-content: center; +`; + +const LoginTitle = styled.h1` + font-size: 32px; + font-weight: 700; + margin-bottom: 16px; +`; +const Card = styled.section` + width: 100%; + max-width: 420px; + border: 1px solid #eee; + border-radius: 16px; + padding: 24px; +`; + +const Form = styled.form` + display: grid; + gap: 12px; + + label { + display: grid; + gap: 6px; + font-size: 14px; + } + + input { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #ddd; + } + + button { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #ddd; + background: white; + cursor: pointer; + } +`; + +const Error = styled.p` + margin: 0; +`; \ No newline at end of file diff --git a/frontend/src/pages/PlaceDetails.jsx b/frontend/src/pages/PlaceDetails.jsx new file mode 100644 index 000000000..d2fbf4752 --- /dev/null +++ b/frontend/src/pages/PlaceDetails.jsx @@ -0,0 +1,253 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import styled from "styled-components"; +import { getPlace } from "../api/places"; +import { getReviews, createReview } from "../api/reviews"; +import { useAuthStore } from "../store/authStore"; + +export default function PlaceDetails() { + const { id } = useParams(); + const accessToken = useAuthStore((state) => state.accessToken); + + const [place, setPlace] = useState(null); + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const [rating, setRating] = useState("5"); + const [comment, setComment] = useState(""); + const [reviewError, setReviewError] = useState(""); + const [success, setSuccess] = useState(""); + + useEffect(() => { + const loadPlaceDetails = async () => { + try { + setLoading(true); + setError(""); + + const placeResponse = await getPlace(id); + const reviewsResponse = await getReviews(id); + + setPlace(placeResponse.place); + setReviews(reviewsResponse.reviews || []); + } catch (err) { + setError(err.message || "Something went wrong"); + } finally { + setLoading(false); + } + }; + + loadPlaceDetails(); + }, [id]); + + const handleSubmitReview = async (event) => { + event.preventDefault(); + setReviewError(""); + setSuccess(""); + + if (!accessToken) { + setReviewError("You need to log in first."); + return; + } + + try { + await createReview( + id, + { + rating: Number(rating), + comment: comment.trim(), + }, + accessToken + ); + + const placeResponse = await getPlace(id); + const reviewsResponse = await getReviews(id); + + setPlace(placeResponse.place); + setReviews(reviewsResponse.reviews || []); + + setRating("5"); + setComment(""); + setSuccess("Review added!"); + } catch (err) { + setReviewError(err.message || "Could not post review"); + } + }; + + if (loading) { + return Loading...; + } + + if (error) { + return ( + + {error} + + ); + } + + if (!place) { + return Place not found.; + } + + return ( + + Back + + {place.name} + {place.city} + + + ⭐ {place.avgRating ?? 0} ({place.reviewCount ?? 0} reviews) + + +
+

Features

+
    +
  • Changing table available: {place.features?.changingTable ? "Yes" : "No"}
  • +
  • Baby lounge available: {place.features?.babyLounge ? "Yes" : "No"}
  • +
  • Stroller friendly: {place.features?.strollerAccess ? "Yes" : "No"}
  • +
  • Wheelchair accessible: {place.features?.accessible ? "Yes" : "No"}
  • +
  • Disposable changing mats available: {place.features?.disposableMats ? "Yes" : "No"}
  • +
  • Diaper disposal bags available: {place.features?.diaperBags ? "Yes" : "No"}
  • +
  • Cleanliness: {place.features?.clean ? "Yes" : "No"}
  • +
+
+ +
+

Reviews

+ + {reviews.length === 0 ? ( +

No reviews yet.

+ ) : ( + reviews.map((review) => ( + + ⭐ {review.rating} + {review.comment &&

{review.comment}

} +
+ )) + )} +
+ +
+

Leave a review

+ + {!accessToken ? ( +

+ Please log in to leave a review. +

+ ) : ( +
+ + +