diff --git a/README.md b/README.md
index 31466b54c2..800e7b2b5a 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,37 @@
# Final Project
-Replace this readme with your own information about your project.
-
-Start by briefly describing the assignment in a sentence or two. Keep it short and to the point.
+A full-stack web application where users can discover and create flea markets (“loppis”) nearby. The app allows people to browse events on a map, add their own flea markets with details and images, and interact with the community.
## The problem
-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?
+We wanted to make it easier for people to find and share local flea markets in a simple and user-friendly way. To achieve this, we built a responsive web app with the following approach:
+
+- **Planning:** We started by breaking down the project into core features, user registration & login, map integration, creating new flea markets, and browsing events. We also created a design in figma and a flowchart.
+
+- **Frontend:** Built with React, Tailwind CSS, React Router, Zustand, and Leaflet for interactive maps. We also used libraries for e.g. keen-slider, react focus lock and then lucide for icons.
+
+- **Backend:** Node.js with Express, MongoDB (Atlas) for data storage, Nominatim API for Open Source Street Map and Cloudinary for image handling. Swagger/OpenAPI for API documentation. Also other libraries like multer to be able to handle formdata submittion with different file formats.
+
+- **Techniques:** We focused on reusable components, clean state management with hooks, and a mobile-first design.
+
+- **Next steps:**
+With more time, we would like to:
+- Allow users to upload a profile picture, edit contact information, and choose dark mode.
+- Automatically filter out flea markets with past dates.
+- Add more flexible scheduling options when creating an event, e.g. “every Sunday until further notice.”
+- Let users click “add to calendar” so their calendar app opens with the flea market details pre-filled.
+- Improve the user experience with more transitions and animations.
+- Implement a global error handler to manage larger issues such as network failures.
+
+# Credits
+
+- [Bianca Van Dijk](https://pixabay.com/users/biancavandijk-9606149/) – images from Pixabay
## View it live
-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
+[Live demo on Netlify]
+https://runthornet.netlify.app/
+
+
+[Backend on Render]
+https://runthornet-api.onrender.com/
\ No newline at end of file
diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js
new file mode 100644
index 0000000000..5a8701966e
--- /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/Like.js b/backend/models/Like.js
new file mode 100644
index 0000000000..37fdb16caa
--- /dev/null
+++ b/backend/models/Like.js
@@ -0,0 +1,23 @@
+import mongoose from "mongoose"
+
+const likeSchema = new mongoose.Schema({
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true
+ },
+ loppis: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Loppis",
+ required: true
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now
+ }
+})
+
+// Ensure that a user can only like a loppis once
+likeSchema.index({ user: 1, loppis: 1 }, { unique: true })
+
+export const Like = mongoose.model("Like", likeSchema)
\ No newline at end of file
diff --git a/backend/models/Loppis.js b/backend/models/Loppis.js
new file mode 100644
index 0000000000..f290a3ff72
--- /dev/null
+++ b/backend/models/Loppis.js
@@ -0,0 +1,114 @@
+import mongoose from 'mongoose'
+
+const loppisSchema = new mongoose.Schema({
+ title: {
+ type: String,
+ required: true,
+ minLength: 5,
+ maxLength: 50
+ },
+ location: {
+ address: {
+ street: String,
+ city: String,
+ postalCode: String,
+ country: {
+ type: String,
+ default: 'Sweden'
+ }
+ },
+ coordinates: {
+ type: {
+ type: String,
+ enum: ['Point'],
+ default: 'Point'
+ },
+ coordinates: {
+ type: [Number], // [longitude, latitude]
+ required: true
+ },
+ },
+ },
+ dates: [
+ {
+ date: {
+ type: Date,
+ required: true
+ },
+ startTime: {
+ type: String, // HH:MM format
+ required: true
+ },
+ endTime: {
+ type: String, // HH:MM format
+ required: true
+ }
+ }
+ ],
+ categories: {
+ type: [String],
+ required: true,
+ enum: [
+ "Vintage",
+ "Barn",
+ "Trädgård",
+ "Kläder",
+ "Möbler",
+ "Böcker",
+ "Husdjur",
+ "Elektronik",
+ "Kök",
+ "Blandat"
+ ],
+ default: "Blandat"
+ },
+ description: {
+ type: String,
+ maxLength: 500
+ },
+ likes: {
+ type: Number,
+ default: 0,
+ min: 0
+ },
+ images: [{ type: String }], // bara public_id
+ coverImage: { type: String }, // public_id
+ createdAt: {
+ type: Date,
+ default: Date.now
+ },
+ createdBy: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }
+})
+
+// Pre-save hook to automatically geocode address
+loppisSchema.pre('save', async function (next) {
+ if (this.isModified('location.address')) {
+
+ const { street, city, postalCode } = this.location.address
+ const query = `${street}, ${postalCode} ${city}, Sweden`
+
+ try {
+ const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`
+ const response = await fetch(url)
+ const data = await response.json()
+
+ if (data.length > 0) {
+ const { lat, lon } = data[0]
+ this.location.coordinates = {
+ type: 'Point',
+ coordinates: [parseFloat(lon), parseFloat(lat)]
+ }
+ } else {
+ throw new Error('Address not found')
+ }
+ } catch (error) {
+ return next(error)
+ }
+ }
+ next()
+})
+
+export const Loppis = mongoose.model('Loppis', loppisSchema)
\ No newline at end of file
diff --git a/backend/models/User.js b/backend/models/User.js
new file mode 100644
index 0000000000..4cc1cc106b
--- /dev/null
+++ b/backend/models/User.js
@@ -0,0 +1,27 @@
+import mongoose from "mongoose"
+import crypto from "crypto"
+
+const userSchema = new mongoose.Schema({
+ firstName: {
+ type: String,
+ required: true,
+ },
+ lastName: {
+ type: String,
+ },
+ 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..39a747c412 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -12,9 +12,18 @@
"@babel/core": "^7.17.9",
"@babel/node": "^7.16.8",
"@babel/preset-env": "^7.16.11",
+ "bcrypt-nodejs": "^0.0.3",
+ "cloudinary": "^2.7.0",
"cors": "^2.8.5",
+ "dotenv": "^17.2.1",
"express": "^4.17.3",
+ "express-list-endpoints": "^7.1.1",
+ "luxon": "^3.7.1",
"mongoose": "^8.4.0",
- "nodemon": "^3.0.1"
+ "multer": "^2.0.2",
+ "nodemon": "^3.0.1",
+ "sharp": "^0.34.3",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1"
}
-}
\ No newline at end of file
+}
diff --git a/backend/routes/geocodeRoutes.js b/backend/routes/geocodeRoutes.js
new file mode 100644
index 0000000000..a0eaaf9e52
--- /dev/null
+++ b/backend/routes/geocodeRoutes.js
@@ -0,0 +1,137 @@
+import express from "express"
+
+const router = express.Router()
+
+// Swagger
+/**
+ * @openapi
+ * tags:
+ * name: Geocoding
+ * description: Endpoints for converting between addresses and geographic coordinates using OpenStreetMap Nominatim.
+ */
+
+// Forward geocoding route
+/**
+ * @openapi
+ * /api/geocode:
+ * get:
+ * tags:
+ * - Geocoding
+ * summary: Forward geocode an address or place
+ * description: Uses OpenStreetMap Nominatim to convert an address or place name into coordinates.
+ * parameters:
+ * - in: query
+ * name: q
+ * required: true
+ * schema:
+ * type: string
+ * description: Search query (address, place name, etc.)
+ * responses:
+ * 200:
+ * description: Geocoding result
+ * content:
+ * application/json:
+ * schema:
+ * type: array
+ * items:
+ * type: object
+ * 400:
+ * description: Missing query parameter
+ * 500:
+ * description: Upstream error
+ */
+router.get("/", async (req, res) => {
+ const q = req.query.q
+ if (!q) return res.status(400).json({ error: "Missing q" })
+
+ // Build Nominatim URL
+ const url = new URL("https://nominatim.openstreetmap.org/search")
+ url.searchParams.set("q", String(q))
+ url.searchParams.set("format", "json")
+ url.searchParams.set("limit", "1")
+ url.searchParams.set("addressdetails", "1")
+ url.searchParams.set("countrycodes", "se")
+
+ try {
+ const r = await fetch(url, {
+ headers: {
+ // Required by Nominatim policy: identify your app + contact
+ "User-Agent": `LoppisApp/1.0 (${process.env.GEOCODER_CONTACT ?? "malinelundgren1991@gmail.com"})`,
+ "Accept": "application/json",
+ },
+ })
+ if (!r.ok) {
+ return res.status(r.status).json({ error: "Geocoding failed" })
+ }
+ const data = await r.json()
+ res.json(data)
+ } catch (e) {
+ console.error("Geocode proxy error:", e)
+ res.status(500).json({ error: "Upstream error" })
+ }
+})
+
+// Reverse geocoding route
+/**
+ * @openapi
+ * /api/geocode/reverse:
+ * get:
+ * tags:
+ * - Geocoding
+ * summary: Reverse geocode coordinates
+ * description: Uses OpenStreetMap Nominatim to convert latitude/longitude into a human-readable address.
+ * parameters:
+ * - in: query
+ * name: lat
+ * required: true
+ * schema:
+ * type: number
+ * description: Latitude
+ * - in: query
+ * name: lon
+ * required: true
+ * schema:
+ * type: number
+ * description: Longitude
+ * responses:
+ * 200:
+ * description: Reverse geocoding result
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * 400:
+ * description: Missing coordinates
+ * 500:
+ * description: Upstream error
+ */
+router.get("/reverse", async (req, res) => {
+ const { lat, lon } = req.query
+ if (!lat || !lon) return res.status(400).json({ error: "Missing coordinates" })
+
+ // Nominatim URL
+ const url = new URL("https://nominatim.openstreetmap.org/reverse")
+ url.searchParams.set("lat", lat)
+ url.searchParams.set("lon", lon)
+ url.searchParams.set("format", "json")
+
+ try {
+ const r = await fetch(url, {
+ headers: {
+ // Required by Nominatim policy: identify your app + contact
+ "User-Agent": `LoppisApp/1.0 (${process.env.GEOCODER_CONTACT ?? "malinelundgren1991@gmail.com"})`,
+ "Accept": "application/json",
+ },
+ })
+ if (!r.ok) {
+ return res.status(r.status).json({ error: "Geocoding failed" })
+ }
+ const data = await r.json()
+ res.json(data)
+ } catch (e) {
+ console.error("Geocode proxy error:", e)
+ res.status(500).json({ error: "Upstream error" })
+ }
+})
+
+export default router
\ No newline at end of file
diff --git a/backend/routes/loppisRoutes.js b/backend/routes/loppisRoutes.js
new file mode 100644
index 0000000000..dac73ead5b
--- /dev/null
+++ b/backend/routes/loppisRoutes.js
@@ -0,0 +1,901 @@
+import 'dotenv/config'
+import express from "express"
+import mongoose from "mongoose"
+import multer from 'multer'
+
+import { v2 as cloudinary } from 'cloudinary'
+import { Loppis } from "../models/Loppis.js"
+import { Like } from '../models/Like.js'
+import { authenticateUser } from "../middleware/authMiddleware.js"
+
+const router = express.Router()
+
+// Multer: store in memory
+const upload = multer({ storage: multer.memoryStorage() })
+
+// Cloudinary config
+cloudinary.config({
+ cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
+ api_key: process.env.CLOUDINARY_API_KEY,
+ api_secret: process.env.CLOUDINARY_API_SECRET,
+})
+
+// swagger comments
+/**
+ * @openapi
+ * tags:
+ * name: Loppis
+ * description: API endpoints for managing loppis ads
+ * components:
+ * securitySchemes:
+ * bearerAuth:
+ * type: http
+ * scheme: bearer
+ * bearerFormat: JWT
+ * schemas:
+ * Loppis:
+ * type: object
+ * required:
+ * - title
+ * - categories
+ * - dates
+ * properties:
+ * _id:
+ * type: string
+ * title:
+ * type: string
+ * minLength: 5
+ * maxLength: 50
+ * description:
+ * type: string
+ * maxLength: 500
+ * categories:
+ * type: array
+ * items:
+ * type: string
+ * enum: ["Vintage","Barn","Trädgård","Kläder","Möbler","Böcker","Husdjur","Elektronik","Kök","Blandat"]
+ * likes:
+ * type: integer
+ * minimum: 0
+ * dates:
+ * type: array
+ * items:
+ * type: object
+ * required:
+ * - date
+ * - startTime
+ * - endTime
+ * properties:
+ * date:
+ * type: string
+ * format: date
+ * startTime:
+ * type: string
+ * pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$"
+ * endTime:
+ * type: string
+ * pattern: "^([01]\\d|2[0-3]):([0-5]\\d)$"
+ * images:
+ * type: array
+ * items:
+ * type: string
+ * description: Cloudinary public_id
+ * coverImage:
+ * type: string
+ * description: Cloudinary public_id of cover image
+ * location:
+ * type: object
+ * properties:
+ * address:
+ * type: object
+ * properties:
+ * street:
+ * type: string
+ * city:
+ * type: string
+ * postalCode:
+ * type: string
+ * country:
+ * type: string
+ * default: Sweden
+ * coordinates:
+ * type: object
+ * properties:
+ * type:
+ * type: string
+ * enum: ["Point"]
+ * coordinates:
+ * type: array
+ * items:
+ * type: number
+ * description: [longitude, latitude]
+ * createdAt:
+ * type: string
+ * format: date-time
+ * createdBy:
+ * type: string
+ * description: MongoDB ObjectId referencing the User
+ */
+
+// get all loppis ads
+/**
+ * @openapi
+ * /loppis:
+ * get:
+ * summary: Get all loppis ads
+ * tags: [Loppis]
+ * responses:
+ * 200:
+ * description: List of loppis ads
+ * content:
+ * application/json:
+ * schema:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Loppis'
+ */
+router.get("/", async (req, res) => {
+ const page = req.query.page || 1
+ const limit = req.query.limit || 20
+ const sortBy = req.query.sort_by || "-createdAt" // sort on most recently added by default
+
+ try {
+ const { city, date, category } = req.query
+ const query = {}
+ if (city) {
+ query['location.address.city'] = new RegExp(city, "i")
+ }
+ if (category) {
+ query.categories = category
+ }
+ if (date) {
+ const now = new Date()
+ if (date === 'today') {
+ query['dates.date'] = {
+ $gte: new Date(now.setHours(0, 0, 0, 0)),
+ $lt: new Date(now.setHours(23, 59, 59, 999))
+ }
+ } else if (date === 'tomorrow') {
+ const tomorrow = new Date()
+ tomorrow.setDate(tomorrow.getDate() + 1)
+ query['dates.date'] = {
+ $gte: tomorrow.setHours(0, 0, 0, 0),
+ $lt: tomorrow.setHours(23, 59, 59, 999)
+ }
+ } else if (date === 'weekend') {
+ const saturday = new Date()
+ saturday.setDate(saturday.getDate() + (6 - saturday.getDay()))
+ saturday.setHours(0, 0, 0, 0)
+
+ const sunday = new Date(saturday)
+ sunday.setDate(saturday.getDate() + 1)
+ sunday.setHours(23, 59, 59, 999)
+
+ query['dates.date'] = {
+ $gte: saturday,
+ $lt: sunday
+ }
+ } else if (date === 'next_week') {
+ const nextMonday = new Date()
+ nextMonday.setDate(nextMonday.getDate() + (9 - nextMonday.getDay()))
+ nextMonday.setHours(0, 0, 0, 0)
+
+ const nextSunday = new Date(nextMonday)
+ nextSunday.setDate(nextMonday.getDate() + 5)
+ nextSunday.setHours(23, 59, 59, 999)
+
+ query['dates.date'] = {
+ $gte: nextMonday,
+ $lt: nextSunday
+ }
+ }
+ }
+
+ const totalCount = await Loppis.find(query).countDocuments()
+ const loppises = await Loppis.find(query).sort(sortBy).skip((page - 1) * limit).limit(limit)
+
+ if (loppises.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No ads found on that query. Try another one."
+ })
+ }
+ res.status(200).json({
+ success: true,
+ response: {
+ totalCount: totalCount,
+ currentPage: page,
+ limit: limit,
+ data: loppises,
+ }
+ })
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Server error while fetching ads."
+ })
+ }
+})
+
+// get popular loppis (most likes)
+/**
+ * @openapi
+ * /loppis/popular:
+ * get:
+ * tags:
+ * - Loppis
+ * summary: Get popular loppis (most likes)
+ * parameters:
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * description: Number of loppis to return (default 10)
+ * responses:
+ * 200:
+ * description: Popular loppis
+ */
+router.get("/popular", async (req, res) => {
+ const limit = req.query.limit || 10 // returns the 10 most liked loppis by default
+
+ try {
+ const popularLoppis = await Loppis.find().sort("-likes").limit(limit)
+
+ if (popularLoppis.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No Loppis found on that query. Try another one."
+ })
+ }
+ res.status(200).json({
+ success: true,
+ response: popularLoppis
+ })
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to fetch popular Loppis."
+ })
+ }
+})
+
+// get upcoming loppis
+/**
+ * @openapi
+ * /loppis/upcoming:
+ * get:
+ * tags:
+ * - Loppis
+ * summary: Get upcoming loppis (sorted by next date)
+ * parameters:
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * description: Number of loppis to return (default 5)
+ * responses:
+ * 200:
+ * description: Upcoming loppis
+ */
+router.get("/upcoming", async (req, res) => {
+ const limit = req.query.limit || 5 // returns the next 5 upcoming loppis by defualt
+ try {
+ const today = new Date()
+ const upcomingLoppis = await Loppis.aggregate([
+ // flatten dates array
+ { $unwind: "$dates" },
+ // only keep future dates
+ { $match: { "dates.date": { $gte: today } } },
+ // sort by date ascending
+ { $sort: { "dates.date": 1 } },
+ // group back by loppis, keep the first (=earliest) date
+ {
+ $group: {
+ _id: "$_id",
+ title: { $first: "$title" },
+ location: { $first: "$location" },
+ nextDate: { $first: "$dates" } // earliest future date
+ }
+ },
+ // finally sort loppisar by their next date
+ { $sort: { "nextDate.date": 1 } }
+ ]).limit(limit)
+
+ if (upcomingLoppis.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No Loppis found on that query. Try another one."
+ })
+ }
+ res.status(200).json({
+ success: true,
+ response: upcomingLoppis
+ })
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to fetch popular Loppis."
+ })
+ }
+})
+
+// get the available categories from enums in Loppis model
+/**
+ * @openapi
+ * /loppis/categories:
+ * get:
+ * tags:
+ * - Loppis
+ * summary: Get available categories
+ * responses:
+ * 200:
+ * description: List of categories
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * response:
+ * type: array
+ * items:
+ * type: string
+ */
+router.get("/categories", async (req, res) => {
+
+ try {
+ const categories = Loppis.schema.path("categories").caster.enumValues
+
+ if (!categories || categories.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No categories found."
+ })
+ }
+
+ res.status(200).json({
+ success: true,
+ response: categories,
+ })
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Server error while fetching categories."
+ })
+ }
+})
+
+// get one loppis by id
+/**
+ * @openapi
+ * /loppis/{id}:
+ * get:
+ * summary: Get a single loppis ad by ID
+ * tags: [Loppis]
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: MongoDB ObjectId of the loppis ad
+ * responses:
+ * 200:
+ * description: Loppis ad found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Loppis'
+ * 400:
+ * description: Invalid ID format
+ * 404:
+ * description: Loppis not found
+ */
+router.get("/:id", async (req, res) => {
+ const { id } = req.params
+
+ try {
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return res.status(400).json({
+ sucess: false,
+ response: null,
+ message: "Invalid ID format."
+ })
+ }
+
+ const loppis = await Loppis.findById(id)
+ if (!loppis) {
+ return res.status(404).json({
+ success: false,
+ response: null,
+ message: "Loppis not found!"
+ })
+ }
+ res.status(200).json({
+ success: true,
+ response: loppis
+ })
+ } catch (error) {
+ return res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to fetch loppis."
+ })
+ }
+})
+
+// Like a loppis - only autheticated users
+/**
+ * @openapi
+ * /loppis/{id}/like:
+ * patch:
+ * summary: Like or unlike a loppis ad (authenticated users only)
+ * tags: [Loppis]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: MongoDB ObjectId of the loppis ad
+ * responses:
+ * 200:
+ * description: Loppis liked/unliked successfully
+ * 400:
+ * description: Invalid ID format
+ * 404:
+ * description: Loppis not found
+ * 500:
+ * description: Server error
+ */
+router.patch("/:id/like", authenticateUser, async (req, res) => {
+ const { id } = req.params
+ let action = ''
+
+ try {
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return res.status(400).json({
+ success: false,
+ response: null,
+ message: "Invalid ID format."
+ })
+ }
+
+ const loppis = await Loppis.findById(id)
+ if (!loppis) {
+ return res.status(404).json({
+ success: false,
+ response: null,
+ message: "Loppis not found!"
+ })
+ }
+
+ // check if user has already liked this loppis or not
+ const existingLike = await Like.findOne({ user: req.user._id, loppis: id })
+ if (!existingLike) {
+ action = 'liked'
+ // create a like entry
+ await new Like({ user: req.user._id, loppis: id }).save()
+ // increse loppis likes by one
+ await Loppis.findByIdAndUpdate(id, { $inc: { likes: 1 } }, { new: true, runValidators: true })
+ } else {
+ action = 'unliked'
+ // remove like entry
+ await Like.findByIdAndDelete(existingLike.id)
+ // decrese loppis likes by one
+ await Loppis.findByIdAndUpdate(id, { $inc: { likes: -1 } }, { new: true, runValidators: true })
+ }
+
+ res.status(200).json({
+ success: true,
+ response: {
+ data: loppis,
+ action: action
+ },
+ message: `Loppis ${action} successfully!`
+ })
+
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to like or unlike loppis."
+ })
+ }
+})
+
+// add a loppis ad
+/**
+ * @openapi
+ * /loppis:
+ * post:
+ * summary: Create a new loppis ad (authenticated users only)
+ * tags: [Loppis]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * multipart/form-data:
+ * schema:
+ * type: object
+ * required:
+ * - data
+ * properties:
+ * data:
+ * type: string
+ * description: JSON string with loppis ad fields (title, description, categories, dates, location)
+ * images:
+ * type: array
+ * items:
+ * type: string
+ * format: binary
+ * description: Array of image files (max 6)
+ * responses:
+ * 201:
+ * description: Loppis ad created successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Loppis'
+ * 400:
+ * description: Missing data or invalid input
+ * 500:
+ * description: Server error
+ */
+router.post('/', authenticateUser, upload.array('images', 6), async (req, res) => {
+ const user = req.user
+
+ try {
+
+ if (!req.body?.data) {
+ return res.status(400).json({ success: false, message: 'Missing "data" field in form-data' })
+ }
+
+ const payload = JSON.parse(req.body.data)
+
+ const uploads = (req.files || []).map(file => new Promise((resolve, reject) => {
+ const stream = cloudinary.uploader.upload_stream(
+ {
+ folder: 'loppis', // save original in a folder
+ resource_type: 'image',
+ },
+ (err, result) => {
+ if (err) return reject(err)
+ // save *public_id* to generate all variants at delivery
+ resolve(result.public_id)
+ }
+ )
+ stream.end(file.buffer)
+ }))
+
+ const publicIds = await Promise.all(uploads) // [ "loppis/abc123", ... ]
+ payload.images = publicIds
+ payload.coverImage = publicIds[0] || null
+
+ // spara i DB med mongoose
+ const doc = await Loppis.create(payload)
+ return res.status(201).json({ success: true, response: doc })
+
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.message || 'Server error' })
+ }
+})
+
+// Edit loppis ad
+// Geocode helper
+const geocodeAddress = async ({ street, postalCode, city }) => {
+ const q = `${street}, ${postalCode} ${city}, Sweden`
+ const res = await fetch(`http://localhost:8080/api/geocode?q=${encodeURIComponent(q)}`)
+ if (!res.ok) throw new Error(`Geocode failed: ${res.status}`)
+ const arr = await res.json()
+ if (!Array.isArray(arr) || arr.length === 0) return null
+ const { lat, lon } = arr[0]
+ return { lat: parseFloat(lat), lon: parseFloat(lon) }
+}
+// update loppis ad incl. images via multipart/form-data ===
+/**
+ * @openapi
+ * /loppis/{id}:
+ * patch:
+ * summary: Update a loppis ad (authenticated users only)
+ * tags: [Loppis]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: MongoDB ObjectId of the loppis ad
+ * requestBody:
+ * required: true
+ * content:
+ * multipart/form-data:
+ * schema:
+ * type: object
+ * properties:
+ * data:
+ * type: string
+ * description: JSON string with fields to update
+ * images:
+ * type: array
+ * items:
+ * type: string
+ * format: binary
+ * description: Array of new images to upload (max 6)
+ * responses:
+ * 200:
+ * description: Loppis ad updated successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Loppis'
+ * 400:
+ * description: Invalid ID format or no changes provided
+ * 404:
+ * description: Loppis not found
+ * 422:
+ * description: Could not geocode updated address
+ * 500:
+ * description: Server error
+ */
+router.patch('/:id', authenticateUser, upload.array('images', 6), async (req, res) => {
+ const { id } = req.params
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return res.status(400).json({ success: false, response: null, message: 'Invalid ID format.' })
+ }
+
+ try {
+ const existing = await Loppis.findById(id)
+ if (!existing) {
+ return res.status(404).json({ success: false, response: null, message: 'Loppis not found!' })
+ }
+
+ // 1) Parsa "data" (kommer som string i multipart)
+ let body = req.body || {}
+ if (body && typeof body === 'object' && body.data) {
+ body = typeof body.data === 'string' ? JSON.parse(body.data) : body.data
+ }
+
+ // 2) Ladda upp NYA filer (behåll ordningen)
+ const uploadBufferToCloudinary = (buf) =>
+ new Promise((resolve, reject) => {
+ const stream = cloudinary.uploader.upload_stream(
+ { folder: 'loppis', resource_type: 'image' },
+ (err, result) => (err ? reject(err) : resolve(result.public_id))
+ )
+ stream.end(buf)
+ })
+
+ let uploadedNew = []
+ if (req.files && req.files.length > 0) {
+ uploadedNew = await Promise.all(req.files.map(f => uploadBufferToCloudinary(f.buffer)))
+ // uploadedNew[0] motsvarar {type:'new', index:0} i order
+ }
+
+ // 3) Bygg slutlig bildlista från 'order' (NYTT kontrakt)
+ // Fallback: om 'order' saknas, kör "gammal" väg (images/keep + remove).
+ let finalImages = null
+ let coverImage = null
+ const order = Array.isArray(body.order) ? body.order : null
+ const coverIndex = Number.isInteger(body.coverIndex) ? body.coverIndex : 0
+
+ if (order) {
+ const prev = Array.isArray(existing.images) ? existing.images : []
+ const uploadedMap = uploadedNew.reduce((acc, pid, idx) => (acc[idx] = pid, acc), {})
+ const removedSetFromBody = new Set(Array.isArray(body.removedExistingPublicIds) ? body.removedExistingPublicIds : [])
+
+ // Återskapa lista enligt 'order'
+ const tmp = []
+ for (const token of order) {
+ if (token?.type === 'existing' && token.publicId && prev.includes(token.publicId)) {
+ // hoppa över om den explicit markerats för borttag
+ if (!removedSetFromBody.has(token.publicId)) tmp.push(token.publicId)
+ } else if (token?.type === 'new' && Number.isInteger(token.index)) {
+ const pid = uploadedMap[token.index]
+ if (pid) tmp.push(pid)
+ }
+ }
+ finalImages = tmp
+ coverImage = finalImages[coverIndex] || null
+
+ // Räkna ut vad som ska raderas i Cloudinary:
+ // a) allt som användaren markerat i removedExistingPublicIds
+ // b) PLUS allt som fanns i prev men inte längre finns i finalImages
+ const finalSet = new Set(finalImages)
+ const implicitRemoved = (prev || []).filter(pid => !finalSet.has(pid))
+ const toDelete = new Set([
+ ...removedSetFromBody,
+ ...implicitRemoved,
+ ])
+ // säkerhet: radera inte om den faktiskt finns kvar i final
+ const reallyDelete = [...toDelete].filter(pid => !finalSet.has(pid))
+
+ if (reallyDelete.length) {
+ await Promise.all(
+ reallyDelete.map(pid =>
+ cloudinary.uploader.destroy(pid).catch(() => null) // swallow failures
+ )
+ )
+ }
+
+ } else {
+ // ---- Fallback till din tidigare logik (om en gammal klient skulle anropa) ----
+ const imageMode = (body.imageMode || 'append').toLowerCase()
+ const keepListRaw = Array.isArray(body.images) ? body.images : null
+ const removeList = new Set(Array.isArray(body.removeImages) ? body.removeImages : [])
+
+ let next = existing.images ? [...existing.images] : []
+ if (imageMode === 'replace') {
+ next = [...uploadedNew]
+ } else {
+ if (keepListRaw) {
+ const keepSet = new Set(next)
+ next = keepListRaw.filter(pid => keepSet.has(pid))
+ }
+ if (removeList.size > 0) {
+ next = next.filter(pid => !removeList.has(pid))
+ }
+ if (uploadedNew.length > 0) {
+ next = [...next, ...uploadedNew]
+ }
+ }
+
+ finalImages = next
+ if (body.coverImage && finalImages.includes(body.coverImage)) {
+ coverImage = body.coverImage
+ } else if (!existing.coverImage || !finalImages.includes(existing.coverImage)) {
+ coverImage = finalImages[0] || null
+ } else {
+ coverImage = existing.coverImage
+ }
+
+ if (removeList.size > 0) {
+ await Promise.all(
+ [...removeList].map(pid =>
+ cloudinary.uploader.destroy(pid).catch(() => null)
+ )
+ )
+ }
+ }
+
+ // 4) Fält-uppdateringar (samma som innan)
+ const addrIn = body.location?.address || {}
+ const oldAddr = existing.location?.address || {}
+
+ const streetChanged = addrIn.street?.trim() !== undefined && addrIn.street.trim() !== (oldAddr.street || '')
+ const cityChanged = addrIn.city?.trim() !== undefined && addrIn.city.trim() !== (oldAddr.city || '')
+ const postalCodeChanged = addrIn.postalCode?.trim() !== undefined && addrIn.postalCode.trim() !== (oldAddr.postalCode || '')
+ const addressChanged = streetChanged || cityChanged || postalCodeChanged
+
+ const $set = {}
+ if (body.title !== undefined) $set.title = body.title
+ if (body.description !== undefined) $set.description = body.description
+ if (body.categories !== undefined) $set.categories = body.categories
+ if (body.dates !== undefined) $set.dates = body.dates
+
+ if (addrIn.street !== undefined) $set['location.address.street'] = addrIn.street
+ if (addrIn.city !== undefined) $set['location.address.city'] = addrIn.city
+ if (addrIn.postalCode !== undefined) $set['location.address.postalCode'] = addrIn.postalCode
+
+ // Spara bilder (om vi byggt en ny lista)
+ if (finalImages) {
+ $set.images = finalImages
+ $set.coverImage = coverImage
+ }
+
+ if (addressChanged) {
+ const geo = await geocodeAddress({
+ street: $set['location.address.street'] ?? oldAddr.street,
+ city: $set['location.address.city'] ?? oldAddr.city,
+ postalCode: $set['location.address.postalCode'] ?? oldAddr.postalCode,
+ })
+ if (!geo) {
+ return res.status(422).json({
+ success: false,
+ response: null,
+ message: 'Kunde inte geokoda den nya adressen. Kontrollera stavning/postnummer.',
+ })
+ }
+ $set['location.coordinates'] = {
+ type: 'Point',
+ coordinates: [geo.lon, geo.lat], // [lng, lat]
+ }
+ }
+
+ if (Object.keys($set).length === 0) {
+ return res.status(400).json({
+ success: false,
+ response: null,
+ message: 'No changes provided. Skickade du några fält i "data" eller filer i "images[]"?',
+ })
+ }
+
+ const updated = await Loppis.findByIdAndUpdate(id, { $set }, {
+ new: true,
+ runValidators: true,
+ validateModifiedOnly: true,
+ })
+
+ return res.status(200).json({ success: true, response: updated, message: 'Loppis updated successfully!' })
+ } catch (err) {
+ return res.status(500).json({ success: false, response: null, message: 'Failed to update loppis ad.' })
+ }
+})
+
+// Delete loppis ad
+/**
+ * @openapi
+ * /loppis/{id}:
+ * delete:
+ * summary: Delete a loppis ad (authenticated users only)
+ * tags: [Loppis]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: MongoDB ObjectId of the loppis ad
+ * responses:
+ * 200:
+ * description: Loppis deleted successfully
+ * 400:
+ * description: Invalid ID format
+ * 404:
+ * description: Loppis not found
+ * 500:
+ * description: Server error
+ */
+router.delete("/:id", authenticateUser, async (req, res) => {
+ const { id } = req.params
+
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return res.status(400).json({
+ success: false,
+ response: null,
+ message: "Invalid ID format."
+ })
+ }
+
+ try {
+ const deletedLoppis = await Loppis.findByIdAndDelete(id)
+
+ if (!deletedLoppis) {
+ return res.status(404).json({
+ success: false,
+ response: null,
+ message: "Loppis not found!"
+ })
+ }
+
+ res.status(200).json({
+ success: true,
+ response: deletedLoppis,
+ message: "Loppis deleted successfully!"
+ })
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to delete loppis ad."
+ })
+ }
+})
+
+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..eb0a1ec7a9
--- /dev/null
+++ b/backend/routes/userRoutes.js
@@ -0,0 +1,397 @@
+import express from "express"
+import bcrypt from "bcrypt-nodejs"
+import mongoose from 'mongoose'
+import { User } from "../models/User.js"
+import { Loppis } from "../models/Loppis.js"
+import { Like } from '../models/Like.js'
+import { authenticateUser } from "../middleware/authMiddleware.js"
+
+const router = express.Router()
+
+// Swagger
+/**
+ * @openapi
+ * tags:
+ * name: Users
+ * description: User management & authentication
+ */
+
+// register a new user
+/**
+ * @openapi
+ * /users/register:
+ * post:
+ * summary: Register a new user
+ * tags: [Users]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - firstName
+ * - email
+ * - password
+ * properties:
+ * firstName:
+ * type: string
+ * example: Alice
+ * lastName:
+ * type: string
+ * example: Andersson
+ * email:
+ * type: string
+ * format: email
+ * example: alice@example.com
+ * password:
+ * type: string
+ * format: password
+ * example: mySecret123
+ * responses:
+ * 200:
+ * description: User created successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * message:
+ * type: string
+ * response:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * firstName:
+ * type: string
+ * accessToken:
+ * type: string
+ * 409:
+ * description: User already exists
+ */
+router.post('/register', async (req, res) => {
+ try {
+ const { firstName, lastName, email, password } = req.body
+ // validate input
+ if (!firstName || !email || !password) {
+ return res.status(400).json({
+ success: false,
+ message: "Name, email and password are required",
+ })
+ }
+
+
+ const MIN_PASSWORD = 8
+ if (String(password).length < MIN_PASSWORD) {
+ return res.status(400).json({
+ success: false,
+ message: `Password must be at least ${MIN_PASSWORD} characters long.`
+ })
+ }
+
+ // validate if email already exists
+ const normalizedEmail = String(email).toLowerCase().trim()
+ const existingUser = await User.findOne({ email: normalizedEmail })
+ if (existingUser) {
+ return res.status(409).json({
+ success: false,
+ message: "User already exists",
+ })
+ }
+ // create a new user
+ const salt = bcrypt.genSaltSync()
+ const user = new User({ firstName, lastName, email: email.toLowerCase(), password: bcrypt.hashSync(password, salt) })
+ await user.save()
+
+ res.status(200).json({
+ success: true,
+ message: "User created successfully!",
+ response: {
+ id: user._id,
+ firstName: user.firstName,
+ accessToken: user.accessToken
+ }
+ })
+
+ } catch (error) {
+ return res.status(400).json({
+ success: false,
+ response: error,
+ message: "Failed to create user."
+ })
+ }
+})
+
+// login existing user
+/**
+ * @openapi
+ * /users/login:
+ * post:
+ * summary: Login user and return access token
+ * tags: [Users]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - email
+ * - password
+ * properties:
+ * email:
+ * type: string
+ * format: email
+ * example: alice@example.com
+ * password:
+ * type: string
+ * format: password
+ * example: mySecret123
+ * responses:
+ * 200:
+ * description: Login successful
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * message:
+ * type: string
+ * response:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * firstName:
+ * type: string
+ * accessToken:
+ * type: string
+ * 401:
+ * description: Invalid credentials
+ * 404:
+ * description: User not found
+ */
+router.post('/login', async (req, res) => {
+ try {
+ const { email, password } = req.body
+ // validate input
+ if (!email || !password) {
+ return res.status(400).json({
+ success: false,
+ message: "Email and password are required",
+ })
+ }
+ // validate if user exists
+ const user = await User.findOne({ email: email.toLowerCase() })
+ if (!user) {
+ return res.status(404).json({
+ success: false,
+ message: "User not found",
+ })
+ }
+ // validate password
+ if (user && bcrypt.compareSync(password, user.password)) {
+ res.status(200).json({
+ success: true,
+ message: "Log in successful!",
+ response: {
+ id: user._id,
+ firstName: user.firstName,
+ accessToken: user.accessToken
+ }
+ })
+ } else {
+ res.status(401).json({
+ success: false,
+ message: "Invalid credentials",
+ })
+ }
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ message: "Failed to log in",
+ error,
+ })
+ }
+})
+
+// list loppis created by (authenticated) user
+/**
+ * @openapi
+ * /users/{id}/loppis:
+ * get:
+ * summary: Get all loppis ads created by a user
+ * tags: [Users]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - name: id
+ * in: path
+ * required: true
+ * schema:
+ * type: string
+ * description: User ID
+ * responses:
+ * 200:
+ * description: List of loppis ads created by the user
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * response:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Loppis'
+ * 401:
+ * description: Unauthorized
+ * 500:
+ * description: Server error
+ */
+router.get("/:id/loppis", authenticateUser, async (req, res) => {
+ const userId = req.user._id.toString()
+
+ try {
+ if (!mongoose.Types.ObjectId.isValid(userId)) {
+ return res.status(400).json({
+ success: false,
+ response: null,
+ message: "Invalid userId"
+ })
+ }
+
+ const loppises = await Loppis.find({ createdBy: userId })
+
+ return res.status(200).json({
+ success: true,
+ response: loppises
+ })
+ } catch (error) {
+ return res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to fetch loppis ads for user."
+ })
+ }
+})
+
+// list loppis liked by (authenticated) user
+/**
+ * @openapi
+ * /users/{id}/likes:
+ * get:
+ * summary: Get all loppis ads liked by a user
+ * tags: [Users]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - name: id
+ * in: path
+ * required: true
+ * schema:
+ * type: string
+ * description: User ID
+ * - name: page
+ * in: query
+ * schema:
+ * type: integer
+ * default: 1
+ * - name: limit
+ * in: query
+ * schema:
+ * type: integer
+ * default: 10
+ * responses:
+ * 200:
+ * description: List of liked loppis ads
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * success:
+ * type: boolean
+ * response:
+ * type: object
+ * properties:
+ * totalCount:
+ * type: integer
+ * currentPage:
+ * type: integer
+ * limit:
+ * type: integer
+ * data:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Loppis'
+ * message:
+ * type: string
+ * 401:
+ * description: Unauthorized
+ * 404:
+ * description: No liked loppis found
+ * 500:
+ * description: Server error
+ */
+router.get("/:id/likes", authenticateUser, async (req, res) => {
+ const user = req.user
+ const page = req.query.page || 1
+ const limit = req.query.limit || 10
+
+ try {
+ if (!mongoose.Types.ObjectId.isValid(user._id)) {
+ return res.status(400).json({
+ success: false,
+ response: null,
+ message: "Invalid user ID format."
+ })
+ }
+
+ const userLikes = await Like.find({ user: user })
+ if (!userLikes || userLikes.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No likes found for this user."
+ })
+ }
+
+ const likedLoppis = await Loppis.find({ _id: { $in: userLikes.map(like => like.loppis) } })
+ .sort("-createdAt").skip((page - 1) * limit).limit(limit)
+
+ if (likedLoppis.length === 0) {
+ return res.status(404).json({
+ success: false,
+ response: [],
+ message: "No liked loppis found for this user."
+ })
+ }
+
+ res.status(200).json({
+ success: true,
+ response: {
+ totalCount: likedLoppis.length,
+ currentPage: page,
+ limit: limit,
+ data: likedLoppis,
+ },
+ message: "Successfully fetched liked loppis ads."
+ })
+
+ } catch (error) {
+ res.status(500).json({
+ success: false,
+ response: error,
+ message: "Failed to fetch liked loppis ads."
+ })
+ }
+})
+
+export default router
\ No newline at end of file
diff --git a/backend/server.js b/backend/server.js
index 070c875189..101769a64c 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,22 +1,39 @@
-import express from "express";
-import cors from "cors";
-import mongoose from "mongoose";
+import 'dotenv/config'
+import express from "express"
+import cors from "cors"
+import mongoose from "mongoose"
+import listEndpoints from "express-list-endpoints"
+import loppisRoutes from "./routes/loppisRoutes.js"
+import userRoutes from './routes/userRoutes.js'
+import geocodeRoutes from './routes/geocodeRoutes.js'
+import { swaggerDocs } from "./swagger.js"
-const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project";
-mongoose.connect(mongoUrl);
-mongoose.Promise = Promise;
+const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"
+mongoose.connect(mongoUrl)
-const port = process.env.PORT || 8080;
-const app = express();
+const port = process.env.PORT || 8080
+const app = express()
-app.use(cors());
-app.use(express.json());
+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 the LoppisApp API.",
+ documentation: "API documentation available at '/api-docs'.",
+ endpoints: endpoints
+ })
+})
+
+// end point routes
+app.use("/users", userRoutes)
+app.use("/loppis", loppisRoutes)
+app.use("/api/geocode", geocodeRoutes)
// Start the server
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
-});
+ swaggerDocs(app) // enable Swagger
+})
diff --git a/backend/swagger.js b/backend/swagger.js
new file mode 100644
index 0000000000..ff1913c94a
--- /dev/null
+++ b/backend/swagger.js
@@ -0,0 +1,44 @@
+import swaggerJsdoc from "swagger-jsdoc";
+import swaggerUi from "swagger-ui-express";
+
+const options = {
+ definition: {
+ openapi: "3.0.0",
+ info: {
+ title: "Loppis API",
+ version: "1.0.0",
+ description: "API documentation for LoppisApp project",
+ },
+ servers: [
+ {
+ url: 'http://localhost:8080/',
+ description: 'Local development server',
+ },
+ {
+ url: 'https://runthornet-api.onrender.com',
+ description: 'Production server (Render)',
+ },
+ ],
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ bearerFormat: 'JWT',
+ },
+ },
+ },
+ security: [
+ {
+ bearerAuth: [],
+ },
+ ],
+ },
+ apis: ['./routes/*.js', './models/*.js'] // look for docs inside your route files
+};
+
+const swaggerSpec = swaggerJsdoc(options);
+
+export const swaggerDocs = (app) => {
+ app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
+}
\ No newline at end of file
diff --git a/frontend/.env.production b/frontend/.env.production
new file mode 100644
index 0000000000..5b7d186fe1
--- /dev/null
+++ b/frontend/.env.production
@@ -0,0 +1 @@
+VITE_API_URL='https://runthornet-api.onrender.com'
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 664410b5b9..708ee6f52d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,13 +1,66 @@
-
-
-
-
-
- Technigo React Vite Boiler Plate
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Runt Hörnet
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/netlify.toml b/frontend/netlify.toml
new file mode 100644
index 0000000000..8d0d2c3e72
--- /dev/null
+++ b/frontend/netlify.toml
@@ -0,0 +1,5 @@
+# Redirect all routes to index.html for React Router
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 7b2747e949..7359d264d3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,14 +4,29 @@
"version": "1.0.0",
"type": "module",
"scripts": {
- "dev": "vite",
+ "dev": "vite --host",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
+ "@tailwindcss/vite": "^4.1.11",
+ "date-fns": "^4.1.0",
+ "dotenv": "^17.2.1",
+ "hamburger-react": "^2.5.2",
+ "keen-slider": "^6.8.6",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^0.536.0",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-dropzone": "^14.3.8",
+ "react-focus-lock": "^2.13.6",
+ "react-leaflet": "^4.2.1",
+ "react-leaflet-cluster": "^3.1.0",
+ "react-responsive": "^10.0.1",
+ "react-router-dom": "^7.7.1",
+ "tailwindcss": "^4.1.11",
+ "zustand": "^5.0.7"
},
"devDependencies": {
"@types/react": "^18.2.15",
diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png
new file mode 100644
index 0000000000..6ea109f7ca
Binary files /dev/null and b/frontend/public/favicon-16x16.png differ
diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png
new file mode 100644
index 0000000000..98d9e85ed5
Binary files /dev/null and b/frontend/public/favicon-32x32.png differ
diff --git a/frontend/public/preview.png b/frontend/public/preview.png
new file mode 100644
index 0000000000..9034bedaef
Binary files /dev/null and b/frontend/public/preview.png differ
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0a24275e6e..6c18ab2602 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,52 @@
-export const App = () => {
+import { BrowserRouter, Routes, Route } from 'react-router-dom'
+import Layout from './pages/Layout'
+import Home from './pages/Home'
+import Search from './pages/Search'
+import LoppisInfo from './pages/LoppisInfo'
+import Profile from './pages/Profile'
+import SignUp from './pages/SignUp'
+import AddLoppis from './pages/AddLoppis'
+import NotFound from './pages/NotFound'
+import AboutUs from './pages/AboutUs'
+import ContactUs from './pages/ContactUs'
+import ProtectedPage from './pages/ProtectedPage'
+export const App = () => {
return (
- <>
- Welcome to Final Project!
- >
- );
-};
+
+
+ } >
+ } title='Startsida' />
+ } title='Söksida' />
+ } title='Loppissida' />
+
+
+
+ }
+ title='Profilsida' />
+
+
+
+ } />
+ } />
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ )
+}
diff --git a/frontend/src/assets/botanical-1.jpg b/frontend/src/assets/botanical-1.jpg
new file mode 100644
index 0000000000..a08262cad2
Binary files /dev/null and b/frontend/src/assets/botanical-1.jpg differ
diff --git a/frontend/src/assets/botanical-2.jpg b/frontend/src/assets/botanical-2.jpg
new file mode 100644
index 0000000000..988cd35e68
Binary files /dev/null and b/frontend/src/assets/botanical-2.jpg differ
diff --git a/frontend/src/assets/botanical-3.jpg b/frontend/src/assets/botanical-3.jpg
new file mode 100644
index 0000000000..2176bc77de
Binary files /dev/null and b/frontend/src/assets/botanical-3.jpg differ
diff --git a/frontend/src/assets/botanical-4.jpg b/frontend/src/assets/botanical-4.jpg
new file mode 100644
index 0000000000..2b952553dc
Binary files /dev/null and b/frontend/src/assets/botanical-4.jpg differ
diff --git a/frontend/src/assets/botanical.jpg b/frontend/src/assets/botanical.jpg
new file mode 100644
index 0000000000..3c97f5656f
Binary files /dev/null and b/frontend/src/assets/botanical.jpg differ
diff --git a/frontend/src/assets/circle.jpg b/frontend/src/assets/circle.jpg
new file mode 100644
index 0000000000..e75819916d
Binary files /dev/null and b/frontend/src/assets/circle.jpg differ
diff --git a/frontend/src/assets/default-profile.png b/frontend/src/assets/default-profile.png
new file mode 100644
index 0000000000..9afed7b402
Binary files /dev/null and b/frontend/src/assets/default-profile.png differ
diff --git a/frontend/src/assets/drawing.jpg b/frontend/src/assets/drawing.jpg
new file mode 100644
index 0000000000..c13e12855b
Binary files /dev/null and b/frontend/src/assets/drawing.jpg differ
diff --git a/frontend/src/assets/lines.jpg b/frontend/src/assets/lines.jpg
new file mode 100644
index 0000000000..6cf25de7a8
Binary files /dev/null and b/frontend/src/assets/lines.jpg differ
diff --git a/frontend/src/assets/logo-1.png b/frontend/src/assets/logo-1.png
new file mode 100644
index 0000000000..060169e1bf
Binary files /dev/null and b/frontend/src/assets/logo-1.png differ
diff --git a/frontend/src/assets/loppis-placeholder-image.png b/frontend/src/assets/loppis-placeholder-image.png
new file mode 100644
index 0000000000..be2d19c7f8
Binary files /dev/null and b/frontend/src/assets/loppis-placeholder-image.png differ
diff --git a/frontend/src/assets/monstera-1.jpg b/frontend/src/assets/monstera-1.jpg
new file mode 100644
index 0000000000..464eac54e5
Binary files /dev/null and b/frontend/src/assets/monstera-1.jpg differ
diff --git a/frontend/src/assets/monstera.jpg b/frontend/src/assets/monstera.jpg
new file mode 100644
index 0000000000..c3c970c682
Binary files /dev/null and b/frontend/src/assets/monstera.jpg differ
diff --git a/frontend/src/assets/seeds-1.jpg b/frontend/src/assets/seeds-1.jpg
new file mode 100644
index 0000000000..7465ccfdf2
Binary files /dev/null and b/frontend/src/assets/seeds-1.jpg differ
diff --git a/frontend/src/assets/seeds.jpg b/frontend/src/assets/seeds.jpg
new file mode 100644
index 0000000000..15a851277d
Binary files /dev/null and b/frontend/src/assets/seeds.jpg differ
diff --git a/frontend/src/components/AriaLiveRegion.jsx b/frontend/src/components/AriaLiveRegion.jsx
new file mode 100644
index 0000000000..0c319cf9ab
--- /dev/null
+++ b/frontend/src/components/AriaLiveRegion.jsx
@@ -0,0 +1,43 @@
+import { useEffect, useState } from "react"
+import { useLocation } from "react-router-dom"
+
+const AriaLiveRegion = () => {
+ const { pathname } = useLocation()
+ const [message, setMessage] = useState("")
+
+ useEffect(() => {
+ // Customize messages per route
+ switch (pathname) {
+ case '/':
+ setMessage("Startsida laddad")
+ break
+ case "/search":
+ setMessage("Söksida laddad")
+ break
+ case "/loppis/:loppisId":
+ setMessage("Loppissida laddad")
+ break
+ case "/profile":
+ setMessage("Profilsida laddad")
+ break
+ default:
+ setMessage("Sidan har laddats")
+ }
+
+ // Clear message after screen reader reads it
+ const timeout = setTimeout(() => setMessage(""), 1000)
+ return () => clearTimeout(timeout)
+ }, [pathname])
+
+ return (
+
+ {message}
+
+ )
+}
+
+export default AriaLiveRegion
\ No newline at end of file
diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx
new file mode 100644
index 0000000000..892dbb4405
--- /dev/null
+++ b/frontend/src/components/Button.jsx
@@ -0,0 +1,28 @@
+const Button = ({ text, icon: Icon, type, onClick, active, ariaLabel, classNames }) => {
+ const baseStyles = 'flex justify-center items-center gap-2 rounded-full shadow-md hover:shadow-lg font-medium transition cursor-pointer'
+
+ const colorStyles = // this can me changed later
+ type === 'submit' || active
+ ? 'bg-button text-button-text hover:bg-button-hover hover:text-button-text-hover'
+ : 'bg-white border-2 border-button text-button-text hover:bg-button-hover hover:text-button-text-hover'
+
+ const sizeStyles = // fixed circle size if no text (only icon)
+ !text
+ ? "w-10 h-10"
+ : "py-2 px-4"
+
+ return (
+
+ {Icon && }
+ {text}
+
+ )
+}
+
+export default Button
+
diff --git a/frontend/src/components/CardLink.jsx b/frontend/src/components/CardLink.jsx
new file mode 100644
index 0000000000..81ed6dabe6
--- /dev/null
+++ b/frontend/src/components/CardLink.jsx
@@ -0,0 +1,21 @@
+import { Link } from "react-router-dom"
+
+const CardLink = ({ to, icon: Icon, iconColor, label, className = "" }) => {
+ return (
+
+
+
+
+ {label}
+
+ )
+}
+
+export default CardLink
diff --git a/frontend/src/components/CarouselCard.jsx b/frontend/src/components/CarouselCard.jsx
new file mode 100644
index 0000000000..668c3b1a41
--- /dev/null
+++ b/frontend/src/components/CarouselCard.jsx
@@ -0,0 +1,63 @@
+import { Link } from 'react-router'
+import { format } from 'date-fns'
+import { sv } from 'date-fns/locale'
+import LikeButton from './LikeButton'
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+import { IMG } from '../utils/imageVariants'
+import useLikesStore from '../stores/useLikesStore'
+
+const CarouselCard = ({ loppis, index, total }) => {
+ const { user, token } = useAuthStore()
+ const { openLoginModal } = useModalStore()
+ const { likedLoppisIds, toggleLike } = useLikesStore()
+
+ // check if loppis is liked by current user
+ const isLiked = likedLoppisIds?.includes(loppis._id)
+ // Hämta publicId för omslagsbild:
+ const id = loppis.coverImage ?? loppis.images?.[0] ?? null
+ // format date
+ const dateString = `${format(loppis.dates[0].date, 'EEE d MMM', { locale: sv })}`
+
+ // handle click on like button
+ const likeLoppis = async (e) => {
+ e.stopPropagation() // förhindra att kortet klickas på
+ if (!user || !token) {
+ openLoginModal('Du måste vara inloggad för att gilla en loppis!')
+ return
+ }
+ toggleLike(loppis._id, user.id, token)
+ }
+
+ return (
+
+
+
+ {IMG.card(id) ? (
+
+ ) : (
+
+ Ingen bild
+
+ )}
+
+
+
+
+
{loppis.title}
+
{loppis.location.address.city} • {dateString}
+
+
+ )
+}
+
+export default CarouselCard
\ No newline at end of file
diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx
new file mode 100644
index 0000000000..aa8041fa5b
--- /dev/null
+++ b/frontend/src/components/ConfirmDialog.jsx
@@ -0,0 +1,60 @@
+import { Loader, X } from 'lucide-react'
+
+const ConfirmDialog = ({
+ open,
+ title = 'Bekräfta',
+ message,
+ confirmText = 'Ja',
+ cancelText = 'Nej',
+ onConfirm,
+ onCancel,
+ loading = false,
+}) => {
+ if (!open) return null
+
+ return (
+
+ {/* backdrop */}
+
+ {/* panel */}
+
+
+
{title}
+
+
+
+
+
+
+
+
+
+ {cancelText}
+
+
+ {loading ? : confirmText}
+
+
+
+
+ )
+}
+
+export default ConfirmDialog
diff --git a/frontend/src/components/Details.jsx b/frontend/src/components/Details.jsx
new file mode 100644
index 0000000000..df5838a5a4
--- /dev/null
+++ b/frontend/src/components/Details.jsx
@@ -0,0 +1,13 @@
+const Details = ({ icon: Icon, text }) => {
+
+ return (
+
+
+
+ {text}
+
+
+ )
+}
+
+export default Details
\ No newline at end of file
diff --git a/frontend/src/components/Divider.jsx b/frontend/src/components/Divider.jsx
new file mode 100644
index 0000000000..0d89b4ce65
--- /dev/null
+++ b/frontend/src/components/Divider.jsx
@@ -0,0 +1,5 @@
+const Divider = () => (
+
+)
+
+export default Divider
\ No newline at end of file
diff --git a/frontend/src/components/ErrorMessage.jsx b/frontend/src/components/ErrorMessage.jsx
new file mode 100644
index 0000000000..d682982bd7
--- /dev/null
+++ b/frontend/src/components/ErrorMessage.jsx
@@ -0,0 +1,26 @@
+import { AlertCircle } from "lucide-react"
+
+const VARIANTS = {
+ error: "bg-red-50 border-red-200 text-red-800",
+ warning: "bg-amber-50 border-amber-200 text-amber-800",
+ info: "bg-sky-50 border-sky-200 text-sky-800",
+}
+
+const ICON_CLASSES = "w-4 h-4 shrink-0 mt-0.5"
+
+const ErrorMessage = ({ children, variant = "error", id, className = "" }) => {
+ const styles = VARIANTS[variant] || VARIANTS.error
+ return (
+
+ )
+}
+
+export default ErrorMessage
diff --git a/frontend/src/components/FieldError.jsx b/frontend/src/components/FieldError.jsx
new file mode 100644
index 0000000000..1eee60ad73
--- /dev/null
+++ b/frontend/src/components/FieldError.jsx
@@ -0,0 +1,18 @@
+
+const FieldError = ({ id, show = true, className = '', children }) => {
+ // rendera inget om vi inte uttryckligen ska visa, eller om det saknas text
+ if (!show || !children) return null
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default FieldError
\ No newline at end of file
diff --git a/frontend/src/components/FilterOption.jsx b/frontend/src/components/FilterOption.jsx
new file mode 100644
index 0000000000..a3243cbc04
--- /dev/null
+++ b/frontend/src/components/FilterOption.jsx
@@ -0,0 +1,28 @@
+const FilterOption = ({ type = 'checkbox', name, value, label, checked, onChange, id }) => {
+ const inputId = id || `${name}-${String(value).replace(/\s+/g, "-")}`
+ return (
+
+
+ {label}
+
+ )
+}
+
+export default FilterOption
+
diff --git a/frontend/src/components/FilterTag.jsx b/frontend/src/components/FilterTag.jsx
new file mode 100644
index 0000000000..b9170cbabb
--- /dev/null
+++ b/frontend/src/components/FilterTag.jsx
@@ -0,0 +1,18 @@
+import { X } from 'lucide-react'
+
+const FilterTag = ({ text, onClick }) => {
+ return (
+
+
+ {text}
+
+
+ )
+}
+
+export default FilterTag
diff --git a/frontend/src/components/Input.jsx b/frontend/src/components/Input.jsx
new file mode 100644
index 0000000000..d8c95f3732
--- /dev/null
+++ b/frontend/src/components/Input.jsx
@@ -0,0 +1,35 @@
+const Input = ({ type, id, label, placeholder, value, onChange, required, showLabel, ...rest }) => {
+ // Show fake placeholder only for date/time inputs and when value is empty
+ const showFakePlaceholder =
+ (type === "date" || type === "time") && !value
+
+ return (
+
+
+ {label}
+
+
+
+
+ {/* Fake placeholder for mobile and tablet */}
+ {showFakePlaceholder && (
+
+ {placeholder}
+
+ )}
+
+ )
+}
+
+export default Input
\ No newline at end of file
diff --git a/frontend/src/components/LikeButton.jsx b/frontend/src/components/LikeButton.jsx
new file mode 100644
index 0000000000..cec925269e
--- /dev/null
+++ b/frontend/src/components/LikeButton.jsx
@@ -0,0 +1,29 @@
+import { Heart } from 'lucide-react'
+
+const LikeButton = ({ isLiked, onLike, className = '' }) => {
+ return (
+
+
+
+ )
+}
+
+export default LikeButton
\ No newline at end of file
diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx
new file mode 100644
index 0000000000..e56e295c65
--- /dev/null
+++ b/frontend/src/components/LoginForm.jsx
@@ -0,0 +1,73 @@
+import { useState } from "react"
+import Input from "./Input"
+import Button from "./Button"
+import ErrorMessage from "./ErrorMessage"
+import FieldError from "./FieldError"
+
+const LoginForm = ({ onSubmit, isLoading, error, fieldErrors = {} }) => {
+ const [formData, setFormData] = useState({
+ email: "",
+ password: "",
+ })
+
+ const [touched, setTouched] = useState({ email: false, password: false })
+
+ const handleSubmit = (event) => {
+ event.preventDefault()
+ setTouched({ email: true, password: true })
+ onSubmit(formData.email, formData.password)
+ }
+
+ return (
+
+ )
+}
+
+export default LoginForm
\ No newline at end of file
diff --git a/frontend/src/components/LoppisCard.jsx b/frontend/src/components/LoppisCard.jsx
new file mode 100644
index 0000000000..b827ec9d63
--- /dev/null
+++ b/frontend/src/components/LoppisCard.jsx
@@ -0,0 +1,142 @@
+import { Link } from 'react-router-dom' // viktigt: dom-varianten
+import { format } from 'date-fns'
+import { sv } from 'date-fns/locale'
+import { MapPinned, Clock, CircleX, Loader2 } from 'lucide-react'
+import Tag from './Tag'
+import LikeButton from './LikeButton'
+import Details from './Details'
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+import { IMG } from '../utils/imageVariants'
+import useLikesStore from '../stores/useLikesStore'
+import useLoppisUpdateStore from '../stores/useLoppisUpdateStore'
+
+// Din S kan vara exakt som du hade den:
+const S = {
+ container: {
+ // mindre på mobil ( {
+ const { user, token } = useAuthStore()
+ const { openLoginModal } = useModalStore()
+ const { likedLoppisIds, toggleLike } = useLikesStore()
+ const isUpdating = useLoppisUpdateStore(s => s.updating[loppis._id])
+
+ const id = loppis.coverImage ?? loppis.images?.[0] ?? null
+ const address = `${loppis.location.address.street}, ${loppis.location.address.city}`
+ const dateString = `${format(loppis.dates[0].date, 'EEE d MMMM', { locale: sv })}, kl ${loppis.dates[0].startTime}-${loppis.dates[0].endTime}`
+ const isLiked = likedLoppisIds?.includes(loppis._id)
+
+ const likeLoppis = async (e) => {
+ e.stopPropagation()
+ if (!user || !token) {
+ openLoginModal('Du måste vara inloggad för att gilla en loppis!')
+ return
+ }
+ toggleLike(loppis._id, user.id, token)
+ }
+
+ const isMap = variant === 'map'
+ const shownCats = isMap ? (loppis.categories ?? []).slice(0, 2) : (loppis.categories ?? [])
+
+ return (
+
+
+ {/* Bildcontainer */}
+
+ {IMG.card(id) ? (
+
+ ) : (
+
+ )}
+
+ {/* X-knappen inuti bilden + högre z-index */}
+ {isMap && onClose && (
+
{ e.stopPropagation(); onClose?.() }}
+ className="absolute top-2 right-2 z-20 rounded-full bg-white/90 hover:bg-white p-1.5 shadow"
+ >
+
+
+ )}
+
+ {isUpdating && (
+
+
+
+ )}
+
+
+ {/* Body */}
+
+
+
+
+ {loppis.title}
+
+
+
+
+
+
+ {shownCats.map((category, i) => )}
+ {isMap && loppis.categories?.length > 2 && (
+ +{loppis.categories.length - 2}
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default LoppisCard
diff --git a/frontend/src/components/LoppisForm.jsx b/frontend/src/components/LoppisForm.jsx
new file mode 100644
index 0000000000..dbe75db7b2
--- /dev/null
+++ b/frontend/src/components/LoppisForm.jsx
@@ -0,0 +1,645 @@
+
+import { useState, useEffect, useMemo } from 'react'
+import { ChevronDown, ChevronUp, Camera, Trash2, Loader2 } from 'lucide-react'
+import Input from './Input'
+import Button from './Button'
+import SmallMap from './SmallMap'
+import PhotoDropzone from './PhotoDropzone'
+import FilterTag from './FilterTag'
+import ErrorMessage from './ErrorMessage'
+import FieldError from './FieldError'
+import { errorMessage } from '../utils/errorMessage'
+import { IMG } from '../utils/imageVariants'
+import { geocodeCity } from '../services/geocodingApi'
+import { getLoppisCategories } from '../services/loppisApi'
+
+
+
+const normalizeDateInput = (val) => {
+ if (!val) return ''
+ try {
+ // ISO eller Date → yyyy-mm-dd
+ const d = new Date(val)
+ if (Number.isNaN(d.getTime())) return String(val).slice(0, 10)
+ return d.toISOString().slice(0, 10)
+ } catch {
+ return String(val).slice(0, 10)
+ }
+}
+
+const toInitialState = (initialValues) => {
+ // fallback till tomma värden
+ const iv = initialValues || {}
+ const addr = iv.location?.address || {}
+
+ return {
+ formData: {
+ title: iv.title || '',
+ street: addr.street || '',
+ city: addr.city || '',
+ postalCode: addr.postalCode || '',
+ description: iv.description || '',
+ imageUrl: iv.imageUrl || '',
+ categories: Array.isArray(iv.categories) ? iv.categories : [],
+ },
+ selectedCategories: Array.isArray(iv.categories) ? iv.categories : [],
+ dates: Array.isArray(iv.dates) && iv.dates.length
+ ? iv.dates.map(d => ({
+ date: normalizeDateInput(d.date),
+ startTime: d.startTime || '',
+ endTime: d.endTime || '',
+ }))
+ : [{ date: '', startTime: '', endTime: '' }],
+ coordinates: Array.isArray(iv.location?.coordinates?.coordinates)
+ ? [
+ // backend: [lon, lat] → vi vill [lat, lon]
+ iv.location.coordinates.coordinates[1],
+ iv.location.coordinates.coordinates[0],
+ ]
+ : null,
+ }
+}
+
+const LoppisForm = ({
+ initialValues,
+ submitLabel = 'Spara',
+ title = 'Redigera Loppis',
+ onSubmit, // (payload) => Promise
+ onCancel, // () => void
+}) => {
+ const [categories, setCategories] = useState([])
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [serverError, setServerError] = useState('') // ← global form error (API, etc)
+ const [categoriesError, setCategoriesError] = useState('')// ← fetch categories errors
+ const [geoLoading, setGeoLoading] = useState(false)
+ const [geoError, setGeoError] = useState('')
+
+ // Stabil "init-nyckel" baserad på innehållet (inte referensen)
+ const initKey = useMemo(() => JSON.stringify(initialValues ?? {}), [initialValues])
+
+ // initiera state en gång från initialValues (innehåll)
+ const init = useMemo(() => toInitialState(initialValues), [initKey])
+
+ const [formData, setFormData] = useState(init.formData)
+ const [selectedCategories, setSelectedCategories] = useState(init.selectedCategories)
+ const [dates, setDates] = useState(init.dates)
+ const [coordinates, setCoordinates] = useState(init.coordinates)
+
+ // 🔁 Media från PhotoDropzone (en ENDA lista + removed)
+ const [media, setMedia] = useState({ items: [], removedExistingPublicIds: [] })
+
+ // --- validation state ---
+ const [touched, setTouched] = useState({
+ title: false, street: false, postalCode: false, city: false,
+ })
+ // datesTouched speglar dates.length
+ const [datesTouched, setDatesTouched] = useState(dates.map(() => ({
+ date: false, startTime: false, endTime: false,
+ })))
+
+ // re-init om initialValues ändras
+ useEffect(() => {
+ const next = toInitialState(initialValues)
+ setFormData(next.formData)
+ setSelectedCategories(next.selectedCategories)
+ setDates(next.dates)
+ setCoordinates(next.coordinates)
+ setTouched({ title: false, street: false, postalCode: false, city: false })
+ setDatesTouched(next.dates.map(() => ({ date: false, startTime: false, endTime: false })))
+ setServerError('')
+ setGeoError('')
+ }, [initKey])
+
+
+ const validateField = (key, val) => {
+ const v = String(val || '').trim()
+ switch (key) {
+ case 'title':
+ if (!v) return 'Rubrik är obligatorisk.'
+ if (v.length < 3) return 'Rubriken måste vara minst 3 tecken.'
+ return ''
+ case 'street':
+ if (!v) return 'Gatuadress är obligatorisk.'
+ return ''
+ case 'postalCode': {
+ if (!v) return 'Postnummer är obligatoriskt.'
+ const ok = /^[0-9]{3}\s?[0-9]{2}$/.test(v) // 12345 eller 123 45
+ return ok ? '' : 'Ange ett giltigt postnummer (t.ex. 123 45).'
+ }
+ case 'city':
+ if (!v) return 'Stad är obligatorisk.'
+ return ''
+ default:
+ return ''
+ }
+ }
+
+ const validateDateRow = (row) => {
+ const errs = { date: '', startTime: '', endTime: '', range: '' }
+ if (!row.date) errs.date = 'Datum är obligatoriskt.'
+ if (!row.startTime) errs.startTime = 'Starttid är obligatorisk.'
+ if (!row.endTime) errs.endTime = 'Sluttid är obligatorisk.'
+ // Om alla finns, kontrollera att sluttid > starttid
+ if (row.startTime && row.endTime) {
+ const a = row.startTime
+ const b = row.endTime
+ if (a >= b) errs.range = 'Sluttid måste vara efter starttid.'
+ }
+ return errs
+ }
+
+
+ const fieldErrors = {
+ title: validateField('title', formData.title),
+ street: validateField('street', formData.street),
+ postalCode: validateField('postalCode', formData.postalCode),
+ city: validateField('city', formData.city),
+ }
+ const dateErrors = dates.map(validateDateRow)
+
+ const hasAnyErrors = () => {
+ if (Object.values(fieldErrors).some(Boolean)) return true
+ for (const r of dateErrors) {
+ if (r.date || r.startTime || r.endTime || r.range) return true
+ }
+ return false
+ }
+
+ // --- handlers ---
+ const handleChange = (key) => (e) => {
+ const val = e.target.value
+ setFormData(prev => ({ ...prev, [key]: val }))
+ if (key in touched && touched[key]) {
+ // live-uppdatera fel
+ // (fieldErrors beräknas om via render; ingen extra setState behövs här)
+ }
+ }
+
+ const handleBlur = (key) => () => setTouched(prev => ({ ...prev, [key]: true }))
+
+
+
+ const handleCategoryChange = (e) => {
+ const { value, checked } = e.target
+ if (checked) {
+ setSelectedCategories(prev => [...prev, value])
+ setFormData(prev => ({ ...prev, categories: [...prev.categories, value] }))
+ } else {
+ setSelectedCategories(prev => prev.filter(cat => cat !== value))
+ setFormData(prev => ({ ...prev, categories: prev.categories.filter(cat => cat !== value) }))
+ }
+ }
+
+ const removeCategory = (category) => {
+ setSelectedCategories(prev => prev.filter(cat => cat !== category))
+ setFormData(prev => ({ ...prev, categories: prev.categories.filter(cat => cat !== category) }))
+ }
+
+ const toggleDropdown = () => setIsDropdownOpen(v => !v)
+
+ useEffect(() => {
+ const fetchCategories = async () => {
+ try {
+ setCategoriesError('')
+ const cats = await getLoppisCategories()
+ setCategories(cats || [])
+ } catch (err) {
+ setCategories([])
+ setCategoriesError(errorMessage(err) || 'Kunde inte hämta kategorier just nu.')
+ }
+ }
+ fetchCategories()
+ }, [])
+
+ const fetchCoordinates = async () => {
+ setCoordinates(null)
+ setGeoError('')
+ if (!formData.street || !formData.city) {
+ setGeoError('Fyll i gatuadress och stad först.')
+ return
+ }
+ const address = `${formData.street}, ${formData.postalCode} ${formData.city}, Sweden`
+ try {
+ setGeoLoading(true)
+ const { lat, lon } = await geocodeCity(address)
+ if (!lat || !lon) throw new Error('Kunde inte hitta koordinater för adressen.')
+ setCoordinates([lat, lon])
+ } catch (err) {
+ setCoordinates(null)
+ setGeoError(errorMessage(err) || 'Kunde inte hämta koordinater.')
+ } finally {
+ setGeoLoading(false)
+ }
+ }
+
+
+
+
+ // Cover + initialbilder till dropzone
+ const orderedPublicIds = useMemo(() => {
+ const ids = Array.isArray(initialValues?.images) ? initialValues.images : []
+ const cover = initialValues?.coverImage
+ return cover && ids.includes(cover) ? [cover, ...ids.filter(id => id !== cover)] : ids
+ }, [initKey])
+
+ const initialFilesForDropzone = useMemo(
+ () => orderedPublicIds.map(pid => ({ url: IMG.thumb(pid), publicId: pid })),
+ [orderedPublicIds]
+ )
+
+
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setServerError('')
+
+ setTouched({ title: true, street: true, postalCode: true, city: true })
+ setDatesTouched(dates.map(() => ({ date: true, startTime: true, endTime: true })))
+
+ if (hasAnyErrors()) return
+
+ try {
+ setSubmitting(true)
+
+ const payload = {
+ title: formData.title,
+ dates,
+ location: {
+ address: {
+ street: formData.street,
+ city: formData.city,
+ postalCode: formData.postalCode,
+ }
+ },
+ categories: formData.categories,
+ description: formData.description,
+ }
+
+ const order = []
+ const newFiles = []
+ let newIdx = 0
+ for (const it of media.items) {
+ if (it.kind === 'existing') {
+ order.push({ type: 'existing', publicId: it.publicId })
+ } else {
+ order.push({ type: 'new', index: newIdx })
+ newFiles.push(it.file)
+ newIdx++
+ }
+ }
+
+ const fd = new FormData()
+ fd.append('data', JSON.stringify({
+ ...payload,
+ order,
+ removedExistingPublicIds: media.removedExistingPublicIds,
+ coverIndex: 0,
+ }))
+ for (const f of newFiles) fd.append('images', f)
+
+ await onSubmit?.(fd)
+ } catch (err) {
+ setServerError(errorMessage(err) || 'Något gick fel när annonsen skulle sparas.')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const touchDate = (i, key) => {
+ setDatesTouched(prev => {
+ const next = [...prev]
+ next[i] = { ...next[i], [key]: true }
+ return next
+ })
+ }
+
+ return (
+
+ {title}
+
+ {/* Global/server error */}
+ {serverError ? {serverError} : null}
+
+
+
+ )
+}
+
+
+export default LoppisForm
\ No newline at end of file
diff --git a/frontend/src/components/LoppisList.jsx b/frontend/src/components/LoppisList.jsx
new file mode 100644
index 0000000000..ef11156e02
--- /dev/null
+++ b/frontend/src/components/LoppisList.jsx
@@ -0,0 +1,120 @@
+import { useState } from 'react'
+import { PencilLine, Trash2, Loader2, Check } from 'lucide-react'
+import LoppisCard from "./LoppisCard"
+import Button from './Button'
+
+const LoppisList = ({
+ loppisList,
+ variant = 'search', // 'search' | 'map' | 'profile'
+ onEditCard, // profile
+ onDeleteCard, // profile
+ onMapCardClose, // map: stäng popup
+ deletingId = null,
+ allowEditing = false,
+}) => {
+
+ const [isEditing, setIsEditing] = useState(false)
+ const hasCards = Array.isArray(loppisList) && loppisList.length > 0
+ // används fför att knapparna inte ska vara fokuserbara när de är gömda
+ const isActionsVisible = variant === 'profile' && allowEditing && isEditing
+
+ const listClass =
+ variant === 'profile'
+ // 1 kolumn (mobil) → 2 (padda/md) → 3 (desktop/lg)
+ ? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 justify-items-center items-stretch w-full'
+ : 'flex flex-col gap-4 w-full'
+
+ // Grid-item: låt varje item bli en flex-rad (kort + actions-kolumn)
+ const itemClass =
+ variant === 'profile'
+ ? 'relative flex items-stretch gap-2 w-full'
+ : 'flex items-stretch gap-2 relative w-full'
+ return (
+
+
+ {variant === 'profile' && allowEditing && hasCards && (
+
+
+ setIsEditing(v => !v)}
+ icon={isEditing ? Check : PencilLine}
+ ariaLabel={isEditing ? 'Avsluta redigering' : 'Redigera lista'}
+ type="button"
+ text={isEditing ? 'Klart' : 'Redigera'}
+ title={isEditing ? 'Klart' : 'Redigera'}
+ aria-pressed={isEditing}
+ />
+
+ )}
+
+
+
+ {loppisList.map((loppis, index) => (
+
+ {/* KORTET: glid lite vänster när editeringsläge är på */}
+
+
+
+
+ {/* ACTIONS-KOLUMN: alltid monterad -> kan animeras in/ut */}
+
+
+ {/* Penna ovanför */}
+
+
+
onEditCard?.(loppis)}
+ className="p-2 rounded-3xl bg-white opacity-75 hover:opacity-90 focus:outline-none cursor-pointer"
+ aria-label="Redigera"
+ title="Redigera"
+ disabled={!isActionsVisible || Boolean(deletingId)}
+ tabIndex={isActionsVisible ? 0 : -1}
+ >
+
+
+
+ {/* Soptunna under */}
+
onDeleteCard?.(loppis)}
+ aria-busy={deletingId === loppis._id} // markera aktiv rad
+ className="p-2 rounded-3xl bg-white opacity-75 hover:opacity-90 focus:outline-none cursor-pointer"
+ aria-label="Ta bort"
+ title="Ta bort"
+ disabled={!isActionsVisible || Boolean(deletingId)}
+ tabIndex={isActionsVisible ? 0 : -1}
+ >
+
+ {deletingId === loppis._id
+ ?
+ : }
+
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export default LoppisList
\ No newline at end of file
diff --git a/frontend/src/components/Menu.jsx b/frontend/src/components/Menu.jsx
new file mode 100644
index 0000000000..b3ba0088d3
--- /dev/null
+++ b/frontend/src/components/Menu.jsx
@@ -0,0 +1,9 @@
+const Menu = ({ type, children }) => {
+ return (
+
+ )
+}
+
+export default Menu
\ No newline at end of file
diff --git a/frontend/src/components/MenuItem.jsx b/frontend/src/components/MenuItem.jsx
new file mode 100644
index 0000000000..0412e77943
--- /dev/null
+++ b/frontend/src/components/MenuItem.jsx
@@ -0,0 +1,30 @@
+// src/components/MenuItem.jsx
+import { NavLink } from 'react-router-dom'
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+
+const MenuItem = ({ text, linkTo, onClick, requiresAuth = false }) => {
+ const user = useAuthStore(s => s.user)
+ const openLoginModal = useModalStore(s => s.openLoginModal)
+
+ const handleClick = (e) => {
+
+ if (requiresAuth && !user) {
+ e.preventDefault()
+ openLoginModal('Logga in för att använda den här funktionen.')
+ }
+ onClick?.(e) // t.ex. stäng meny
+ }
+
+ return (
+
+ {text}
+
+ )
+}
+
+export default MenuItem
diff --git a/frontend/src/components/MenuLogo.jsx b/frontend/src/components/MenuLogo.jsx
new file mode 100644
index 0000000000..0de7c8b6a4
--- /dev/null
+++ b/frontend/src/components/MenuLogo.jsx
@@ -0,0 +1,20 @@
+import { Link } from 'react-router-dom'
+import logo from '../assets/logo-1.png'
+
+
+const MenuLogo = () => {
+ return (
+
+
+
+
+
+ )
+}
+
+export default MenuLogo
\ No newline at end of file
diff --git a/frontend/src/components/NavItem.jsx b/frontend/src/components/NavItem.jsx
new file mode 100644
index 0000000000..af283ccf9f
--- /dev/null
+++ b/frontend/src/components/NavItem.jsx
@@ -0,0 +1,32 @@
+// src/components/NavItem.jsx
+import { Link } from 'react-router-dom'
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+
+const NavItem = ({ icon: Icon, ariaLabel, linkTo, text, onClick, requiresAuth = false }) => {
+ const user = useAuthStore(s => s.user)
+ const openLoginModal = useModalStore(s => s.openLoginModal)
+
+ const handleClick = (e) => {
+ if (requiresAuth && !user) {
+ e.preventDefault()
+ openLoginModal('Du behöver vara inloggad för att komma vidare.')
+ }
+ onClick?.(e) // t.ex. stäng meny
+ }
+
+ return (
+
+
+ {text}
+
+ )
+}
+
+export default NavItem
diff --git a/frontend/src/components/NoResults.jsx b/frontend/src/components/NoResults.jsx
new file mode 100644
index 0000000000..a5fc0d1ba1
--- /dev/null
+++ b/frontend/src/components/NoResults.jsx
@@ -0,0 +1,24 @@
+import { AlertCircle } from 'lucide-react'
+
+const NoResults = ({ title, message }) => {
+ return (
+
+
+
+ {title}
+
+
+ {message}
+
+
+ )
+}
+
+export default NoResults
\ No newline at end of file
diff --git a/frontend/src/components/PhotoDropzone.jsx b/frontend/src/components/PhotoDropzone.jsx
new file mode 100644
index 0000000000..1bf680800d
--- /dev/null
+++ b/frontend/src/components/PhotoDropzone.jsx
@@ -0,0 +1,207 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useDropzone } from 'react-dropzone'
+import { ImagePlus, Trash2, Star, StarOff } from 'lucide-react'
+
+
+
+// extrct publicId från Cloudinary-url
+const extractPublicId = (url) => {
+ try {
+ const u = new URL(url)
+ // parse URL into parts
+ // Ex: /image/upload/c_fill,w_300/v16912345/folder/name_abc.jpg
+ const path = u.pathname // get only the path part
+ const i = path.indexOf('/upload/')
+ if (i === -1) return null
+ let rest = path.slice(i + '/upload/'.length)
+ const vMatch = rest.match(/\/v\d+\//)
+ if (vMatch) rest = rest.slice(vMatch.index + vMatch[0].length)
+ return rest.replace(/\.[a-z0-9]+$/i, '')
+ } catch { return null }
+}
+
+// props:
+// initialFiles: array av url:er (strings) och/eller File-objekt
+const PhotoDropzone = ({
+ initialFiles = [],
+ maxFiles = 6,
+ maxSizeMB = 5,
+ onChange,
+ inputId = 'file-input',
+ ariaLabelledBy,
+ ariaLabel, // fallback if ariaLabelledBy is missing
+
+}) => {
+
+ const [items, setItems] = useState([]) // [{kind:'existing'|'new', url?, file?}]
+ const [removedExistingPublicIds, setRemovedExistingPublicIds] = useState([])
+
+
+ // initiera items from initialFiles
+ useEffect(() => {
+ const init = []
+ for (const f of initialFiles) {
+ if (typeof f === 'string') {
+ init.push({ kind: 'existing', url: f, publicId: extractPublicId(f) })
+ } else if (f && typeof f === 'object') {
+
+ if ('url' in f || 'publicId' in f) {
+ init.push({ kind: 'existing', url: f.url, publicId: f.publicId ?? extractPublicId(f.url) })
+ } else if (f instanceof File) {
+ init.push({ kind: 'new', file: f })
+ }
+ }
+ }
+ setItems(init)
+ setRemovedExistingPublicIds([])
+ }, [initialFiles])
+
+ // notify parent on changes
+ useEffect(() => {
+ onChange?.({ items, removedExistingPublicIds })
+ }, [items, removedExistingPublicIds, onChange])
+
+ const maxSizeBytes = maxSizeMB * 1024 * 1024
+ const accept = useMemo(() => ({
+ 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif']
+ }), [])
+
+
+ // dropzone hook
+ const onDrop = useCallback((accepted, rejects) => {
+ rejects.forEach(r => r.errors.forEach(err => {
+ console.warn(`Rejected ${r.file.name}: ${err.code} ${err.message}`)
+ }))
+ if (accepted.length === 0) return
+
+ setItems(prev => {
+ const remainingSlots = Math.max(0, maxFiles - prev.length)
+ const nextNew = accepted.slice(0, remainingSlots).map(f => ({ kind: 'new', file: f }))
+ return [...prev, ...nextNew]
+ })
+ }, [maxFiles])
+
+ // useDropzone hook
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept,
+ maxSize: maxSizeBytes,
+ multiple: true
+ })
+
+ // a11y props
+ const rootProps = getRootProps({
+ role: 'button',
+ ...(ariaLabelledBy
+ ? { 'aria-labelledby': ariaLabelledBy }
+ : { 'aria-label': ariaLabel || 'Ladda upp bilder' }),
+
+ })
+
+ // input props
+ const inputProps = getInputProps({
+ id: inputId,
+ ...(ariaLabelledBy
+ ? { 'aria-labelledby': ariaLabelledBy }
+ : { 'aria-label': ariaLabel || 'Ladda upp bilder' }),
+
+ })
+
+
+ // helper to make an item the cover (first)
+ const makeCover = (idx) => {
+ setItems(prev => {
+ if (idx <= 0 || idx >= prev.length) return prev
+ const copy = [...prev]
+ const [moved] = copy.splice(idx, 1)
+ copy.unshift(moved)
+ return copy
+ })
+ }
+
+ // helper to remove an item at index
+ const removeAt = (idx) => {
+ setItems(prev => {
+ const copy = [...prev]
+ const [removed] = copy.splice(idx, 1)
+ if (removed?.kind === 'existing' && removed.publicId) {
+ setRemovedExistingPublicIds(prevIds => [...prevIds, removed.publicId])
+ }
+ return copy
+ })
+ }
+
+ // Render
+ const hasAny = items.length > 0
+
+ return (
+
+
+
+
+
+
Dra & släpp bilder här, eller klicka för att välja
+
+ Max {maxFiles} bilder • Upp till {maxSizeMB}MB per bild
+
+
+
+
+ {hasAny && (
+
+ {items.map((it, idx) => {
+ const isCover = idx === 0
+ // skapa lokal preview-url för nya filer
+ const preview = it.kind === 'new' ? URL.createObjectURL(it.file) : it.url
+ return (
+
+ { if (it.kind === 'new') URL.revokeObjectURL(preview) }}
+ />
+ {/* badge */}
+
+ {isCover && (
+
+ Omslag
+
+ )}
+
+ {/* actions - alltid synliga */}
+
+ makeCover(idx)}
+ className="p-2 rounded-full bg-white shadow cursor-pointer hover:bg-gray-200"
+ title="Gör till omslag"
+ >
+ {isCover ? : }
+
+ removeAt(idx)}
+ className="p-2 rounded-full bg-white shadow cursor-pointer hover:bg-gray-200"
+ title="Ta bort"
+ >
+
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
+
+export default PhotoDropzone
\ No newline at end of file
diff --git a/frontend/src/components/ScrollToTopAndFocus.jsx b/frontend/src/components/ScrollToTopAndFocus.jsx
new file mode 100644
index 0000000000..646b703f5c
--- /dev/null
+++ b/frontend/src/components/ScrollToTopAndFocus.jsx
@@ -0,0 +1,21 @@
+import { useEffect } from "react"
+import { useLocation } from "react-router-dom"
+
+const ScrollToTopAndFocus = () => {
+ const { pathname } = useLocation()
+
+ useEffect(() => {
+ // scroll to top
+ window.scrollTo(0, 0)
+
+ // shift focus to
+ const main = document.getElementById("main-content")
+ if (main) {
+ main.focus()
+ }
+ }, [pathname])
+
+ return null
+}
+
+export default ScrollToTopAndFocus
\ No newline at end of file
diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx
new file mode 100644
index 0000000000..a477796946
--- /dev/null
+++ b/frontend/src/components/SearchBar.jsx
@@ -0,0 +1,28 @@
+import { Search } from 'lucide-react'
+
+const SearchBar = ({ value, setValue }) => {
+ return (
+
+
+ Sök stad eller område
+
+
+
+
+
+
+ )
+}
+
+export default SearchBar
\ No newline at end of file
diff --git a/frontend/src/components/SmallMap.jsx b/frontend/src/components/SmallMap.jsx
new file mode 100644
index 0000000000..7b7160bc82
--- /dev/null
+++ b/frontend/src/components/SmallMap.jsx
@@ -0,0 +1,36 @@
+import ReactDOMServer from "react-dom/server"
+import { MapContainer, TileLayer, Marker } from "react-leaflet"
+import L from "leaflet"
+import { MapPin } from "lucide-react"
+
+const SmallMap = ({ coordinates }) => {
+
+ // Create a Leaflet divIcon with Lucide SVG
+ const markerIcon = L.divIcon({
+ html: ReactDOMServer.renderToString(
+
+ ),
+ className: "", // Remove default Leaflet styles
+ iconSize: [32, 32],
+ iconAnchor: [16, 16], // Adjust so the "point" is at the right place
+ })
+
+ return (
+
+
+
+
+ )
+}
+
+export default SmallMap
\ No newline at end of file
diff --git a/frontend/src/components/Tag.jsx b/frontend/src/components/Tag.jsx
new file mode 100644
index 0000000000..97e4f865b8
--- /dev/null
+++ b/frontend/src/components/Tag.jsx
@@ -0,0 +1,13 @@
+
+
+const Tag = ({ text, classNames }) => {
+ return (
+
+ {text}
+
+ )
+}
+
+export default Tag
\ No newline at end of file
diff --git a/frontend/src/components/TeamCard.jsx b/frontend/src/components/TeamCard.jsx
new file mode 100644
index 0000000000..dc7d006943
--- /dev/null
+++ b/frontend/src/components/TeamCard.jsx
@@ -0,0 +1,17 @@
+
+
+
+export const TeamCard = ({ name, role, bio, img }) => (
+
+
+ {img ? (
+
+ ) : (
+
Bild
+ )}
+
+
{name}
+
{role}
+ {bio &&
{bio}
}
+
+)
diff --git a/frontend/src/components/ValueCard.jsx b/frontend/src/components/ValueCard.jsx
new file mode 100644
index 0000000000..61ce4ec0bf
--- /dev/null
+++ b/frontend/src/components/ValueCard.jsx
@@ -0,0 +1,13 @@
+const ValueCard = ({ icon: Icon, title, children }) => (
+
+)
+
+export default ValueCard
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e69de29bb2..cbff8af9ad 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -0,0 +1,97 @@
+@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto:ital,wght@0,100..900;1,100..900&display=swap");
+@import 'tailwindcss';
+
+
+/*Leaflet "components"*/
+
+* {
+ font-family: var(--font-primary)
+}
+
+.my-popup .leaflet-popup-content-wrapper {
+ /* använd vanliga CSS för “w-[300px]” */
+ width: 300px;
+ height: auto;
+ z-index: 800 !important;
+
+ /* och @apply för vanliga utilities (utan hakparenteser) */
+ @apply bg-white rounded-lg shadow-lg p-0 m-0;
+}
+
+.leaflet-popup-content-wrapper {
+ background: transparent !important;
+ box-shadow: none !important;
+ border-radius: 0 !important;
+ padding: 0 !important;
+}
+
+/* Ta bort lilla triangeln (tip) */
+.leaflet-popup-tip-container {
+ display: none !important;
+}
+
+/* Se till att ditt innehåll inte får extra margin */
+.leaflet-popup-content {
+ margin: 0 !important;
+}
+
+/* navbar height */
+:root {
+ --nav-height: 64px;
+}
+
+@media (min-width: 768px) {
+ :root {
+ --nav-height: 72px;
+ }
+}
+
+
+@theme {
+
+ /* Font */
+ --font-primary: 'Poppins', sans-serif;
+ --font-secondary: 'Roboto', sans-serif;
+
+ /* light-green */
+ --color-background: #F7F9F5;
+
+ /* dark-green */
+ --color-nav: #2D472F;
+
+ /* Orange */
+ --color-accent: #FF8242;
+
+ /* light-pink */
+ --color-accent-light: #FFEAE0;
+
+ /* light-gray */
+ --color-border: #E2E2E2;
+
+ /* lighter green */
+ --color-hover: #E9F0E4;
+
+ /* green */
+ --color-light: #C2D3B7;
+
+ /* darker green */
+ --color-text: #799466;
+
+ /*----------------------------------------------*/
+
+ --color-button: #fca742;
+ --color-button-hover: #df7d08;
+ --color-button-text: #2c2c2c;
+ --color-button-text-hover: #010101;
+
+ --color-icons: #799466;
+
+
+}
+
+.active {
+ color: #4d633d;
+ font-weight: 600;
+ border-bottom: 2px solid #4d633d;
+ border-radius: 0;
+}
\ No newline at end of file
diff --git a/frontend/src/modals/EditModal.jsx b/frontend/src/modals/EditModal.jsx
new file mode 100644
index 0000000000..50736e747d
--- /dev/null
+++ b/frontend/src/modals/EditModal.jsx
@@ -0,0 +1,45 @@
+import LoppisForm from '../components/LoppisForm'
+import { updateLoppis } from '../services/loppisApi'
+import useAuthStore from '../stores/useAuthStore'
+import useLoppisUpdateStore from '../stores/useLoppisUpdateStore'
+
+const EditModal = ({ open, loppis, onClose, onSaved }) => {
+ const { token } = useAuthStore()
+ const { setUpdating } = useLoppisUpdateStore()
+ if (!open || !loppis) return null
+
+ const editLoppis = async (payload) => {
+ try {
+ // payload ÄR FormData från LoppisForm
+ setUpdating(loppis._id, true)
+ const updated = await updateLoppis(loppis._id, payload, token)
+ onSaved?.(updated)
+ } catch (err) {
+ console.error('Failed to update loppis:', err)
+ } finally {
+ setUpdating(loppis._id, false)
+
+ }
+ }
+
+ return (
+
+
+
+
+ {/* Body: formulär */}
+
+
+
+
+
+ )
+}
+
+export default EditModal
diff --git a/frontend/src/modals/LoginModal.jsx b/frontend/src/modals/LoginModal.jsx
new file mode 100644
index 0000000000..82fd842760
--- /dev/null
+++ b/frontend/src/modals/LoginModal.jsx
@@ -0,0 +1,127 @@
+import { useEffect, useState, useRef } from 'react'
+import { Link } from 'react-router-dom'
+import FocusLock from "react-focus-lock"
+import { X } from 'lucide-react'
+import LoginForm from "../components/LoginForm"
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+
+
+const LoginModal = ({ onClose }) => {
+ // Modal-meddelande
+ const loginMessage = useModalStore(s => s.loginMessage)
+
+ // previously focused element before opening modal
+ const openerRef = useRef(null)
+
+ // Auth store (välj fält separat)
+ const login = useAuthStore(s => s.login)
+ const isLoading = useAuthStore(s => s.isLoading)
+ const authError = useAuthStore(s => s.error)
+ const clearAuthError = useAuthStore(s => s.clearError)
+
+ const [formError, setFormError] = useState("")
+ const [fieldErrors, setFieldErrors] = useState({})
+
+ // clear error and move focus when component mounts
+ useEffect(() => {
+ setFormError("")
+ setFieldErrors({})
+ clearAuthError?.()
+ // save the previously focused element before opening modal
+ openerRef.current = document.activeElement
+ }, [])
+
+ const handleClose = () => {
+ openerRef.current?.focus() // return focus to the button that opened the modal
+ onClose()
+ }
+
+
+ const handleLogin = async (email, password) => {
+ setFormError("")
+ setFieldErrors({})
+
+ const nextFieldErrors = {}
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ if (!email) nextFieldErrors.email = "Ange din e-postadress."
+ else if (!emailRe.test(email)) nextFieldErrors.email = "Ogiltig e-postadress."
+ if (!password) nextFieldErrors.password = "Ange ditt lösenord."
+
+ if (Object.keys(nextFieldErrors).length) {
+ setFieldErrors(nextFieldErrors)
+ return
+ }
+
+ try {
+ await login({ email, password })
+ handleClose()
+ } catch (e) {
+ // Prefer a general form error for bad credentials
+ setFormError("Fel e-post eller lösenord.")
+ }
+ }
+
+ const displayFormError = formError || authError || ''
+
+ return (
+
+ {/* Backdrop overlay (page behind is dimmed) */}
+
+ {/* Modal box */}
+
+
+ {/* Modal title */}
+
Logga in
+
+ {/* Show optional message */}
+ {loginMessage && (
+
+ {loginMessage}
+
+ )}
+
+ {/* Login form */}
+
+
+ {/* Link to signup page */}
+
+ Har du inget konto?
+
+ Registrera dig här
+
+
+
+ {/* Close button */}
+
+
+
+
+
+
+ )
+}
+
+export default LoginModal
\ No newline at end of file
diff --git a/frontend/src/pages/AboutUs.jsx b/frontend/src/pages/AboutUs.jsx
new file mode 100644
index 0000000000..7af2a35b61
--- /dev/null
+++ b/frontend/src/pages/AboutUs.jsx
@@ -0,0 +1,46 @@
+import HeroAbout from '../sections/about/HeroAbout'
+import Story from '../sections/about/Story'
+import Values from '../sections/about/Values'
+import WhyUs from '../sections/about/WhyUs'
+import HowItWorks from '../sections/about/HowItWorks'
+import Team from '../sections/about/Team'
+import Faq from '../sections/about/Faq'
+import Cta from '../sections/about/Cta'
+import HeroImage from '../assets/monstera-1.jpg'
+import Divider from '../components/Divider'
+import Footer from '../sections/Footer'
+
+const AboutUs = () => {
+ const team = [
+ { name: 'Malin', role: 'Utvecklare & Grundare', bio: 'Brinner för enkel UX och återbruk.', img: '' },
+ { name: 'Mimmi', role: 'Utvecklare & Grundare', bio: 'Har en PhD i kemi och ett öga för detaljer.', img: '' },
+ ]
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+
+export default AboutUs
\ No newline at end of file
diff --git a/frontend/src/pages/AddLoppis.jsx b/frontend/src/pages/AddLoppis.jsx
new file mode 100644
index 0000000000..28f67762b9
--- /dev/null
+++ b/frontend/src/pages/AddLoppis.jsx
@@ -0,0 +1,65 @@
+// src/pages/AddLoppis.jsx
+import { useNavigate } from 'react-router-dom'
+import useAuthStore from '../stores/useAuthStore'
+import { createLoppis } from '../services/loppisApi'
+import LoppisForm from '../components/LoppisForm'
+import bgImage from '../assets/botanical.jpg'
+
+const AddLoppis = () => {
+ const { user, token } = useAuthStore()
+ const userId = user?._id ?? user?.id
+ const navigate = useNavigate()
+
+ const addLoppis = async (fd) => {
+ try {
+ // 1) Läs befintlig payload ("data"), lägg till createdBy och skriv tillbaka
+ const raw = fd.get('data')
+ const base = raw ? JSON.parse(raw) : {}
+ const next = { ...base, createdBy: userId }
+
+ fd.delete('data')
+ fd.append('data', JSON.stringify(next))
+
+ // 2) Skicka som multipart/form-data (låter browser sätta Content-Type + boundary)
+ const newLoppis = await createLoppis(fd, token)
+ if (!newLoppis || !newLoppis._id) {
+ throw new Error('Misslyckades att skapa loppis')
+ }
+ // re-direct to the loppis details page
+ navigate(`/loppis/${newLoppis._id}`)
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to create loppis:', err)
+ } finally {
+ // -------------------TODO: handle loading state
+ }
+ }
+
+ const blank = {
+ title: '',
+ description: '',
+ categories: [],
+ dates: [{ date: '', startTime: '', endTime: '' }],
+ location: { address: { street: '', city: '', postalCode: '' } },
+ images: [] // valfritt; LoppisForm/PhotoDropzone bryr sig inte om tom array
+ }
+
+ return (
+
+ navigate('/')}
+ />
+
+ )
+}
+
+export default AddLoppis
diff --git a/frontend/src/pages/ContactUs.jsx b/frontend/src/pages/ContactUs.jsx
new file mode 100644
index 0000000000..b4d3e5a3d2
--- /dev/null
+++ b/frontend/src/pages/ContactUs.jsx
@@ -0,0 +1,40 @@
+import { Mail } from "lucide-react"
+import bgImage from '../assets/botanical-4.jpg'
+
+const Contact = () => {
+ return (
+
+
+ Kontakta oss
+
+ Har du frågor, idéer eller vill komma i kontakt med oss? Tveka inte att höra av dig!
+
+
+
+
+
+ )
+}
+
+export default Contact
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
new file mode 100644
index 0000000000..1f5068f3ea
--- /dev/null
+++ b/frontend/src/pages/Home.jsx
@@ -0,0 +1,33 @@
+import HeroSearch from '../sections/home/HeroSearch'
+import Divider from '../components/Divider'
+import PopularCarousel from '../sections/home/PopularCarousel'
+import CategoryGrid from '../sections/home/CategoryGrid'
+import Upcoming from '../sections/home/Upcoming'
+import CtaHome from '../sections/home/CtaHome'
+import Footer from '../sections/Footer'
+
+const Home = () => {
+ return (
+
+ {/* Hero section with search bar → quick entry point to search*/}
+
+ {/* Carousel section with popular loppis*/}
+
+
+ {/* Categories grid quick links → leads to filtered search */}
+
+
+ {/* Upcoming Loppisar*/}
+
+
+ {/* CTA - Add your own loppis */}
+
+
+
+ )
+}
+
+export default Home
diff --git a/frontend/src/pages/Layout.jsx b/frontend/src/pages/Layout.jsx
new file mode 100644
index 0000000000..ef8d7ea8dc
--- /dev/null
+++ b/frontend/src/pages/Layout.jsx
@@ -0,0 +1,22 @@
+import { Outlet } from 'react-router-dom'
+import TopNav from '../sections/TopNav'
+import LoginModal from '../modals/LoginModal'
+import useModalStore from '../stores/useModalStore'
+import ScrollToTopAndFocus from '../components/ScrollToTopAndFocus'
+import AriaLiveRegion from '../components/AriaLiveRegion'
+
+const Layout = () => {
+ const { loginModalOpen, closeLoginModal } = useModalStore()
+
+ return (
+
+
+
+
+
+ {loginModalOpen &&
}
+
+ )
+}
+
+export default Layout
\ No newline at end of file
diff --git a/frontend/src/pages/LoppisInfo.jsx b/frontend/src/pages/LoppisInfo.jsx
new file mode 100644
index 0000000000..6e5d45827b
--- /dev/null
+++ b/frontend/src/pages/LoppisInfo.jsx
@@ -0,0 +1,280 @@
+import { useEffect, useState } from 'react'
+import { format } from 'date-fns'
+import { sv } from 'date-fns/locale'
+import { useParams, useNavigate } from 'react-router-dom'
+import { ChevronLeft, Clock, MapPinned, Navigation, CalendarDays, Map, Heart, LoaderCircle } from 'lucide-react'
+import Tag from '../components/Tag'
+import Button from '../components/Button'
+import Details from '../components/Details'
+import LikeButton from '../components/LikeButton'
+import SmallMap from '../components/SmallMap'
+import { cldUrl } from '../utils/cloudinaryUrl'
+import { IMG } from '../utils/imageVariants'
+import useLikesStore from '../stores/useLikesStore'
+import useAuthStore from '../stores/useAuthStore'
+import { getLoppisById } from '../services/loppisApi'
+import useModalStore from '../stores/useModalStore'
+import useGeoStore, { distanceKm } from '../stores/useGeoStore'
+import background from "../assets/botanical.jpg"
+
+const LoppisInfo = () => {
+ const { loppisId } = useParams()
+ const { user, token } = useAuthStore()
+ const { likedLoppisIds, toggleLike } = useLikesStore()
+ const { openLoginModal } = useModalStore()
+ const { location } = useGeoStore()
+ const [loppis, setLoppis] = useState({})
+ const [loppisCoords, setLoppisCoords] = useState({}) // {lat: , lng: }
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [heroLoaded, setHeroLoaded] = useState(false)
+ const [galleryLoaded, setGalleryLoaded] = useState([0])
+ const isLiked = likedLoppisIds.includes(loppisId)
+ const navigate = useNavigate()
+ const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${loppisCoords.lat},${loppisCoords.lng}`
+
+ // If the user has granted location - calculate distance to loppis
+ const distance =
+ location && distanceKm({ lat: location.lat, lng: location.lng }, loppisCoords)
+
+ useEffect(() => {
+ const fetchLoppisData = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await getLoppisById(loppisId)
+ setLoppis(data)
+ const coordinates = data.location.coordinates.coordinates // [lon, lat]
+ setLoppisCoords({ lat: coordinates[1], lng: coordinates[0] })
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to fetch loppis data:', err)
+ setError(err.message || 'Kunde inte hämta loppisdata')
+ } finally {
+ setLoading(false)
+ }
+ }
+ fetchLoppisData()
+ }, [loppisId])
+
+ const handleBack = () => {
+ // if the user came directly to the LoppisInfo page via a shared link, there may be no "previous page", in that case, you might want a fallback route
+ if (window.history.state && window.history.state.idx > 0) {
+ navigate(-1) // goes back one step in history
+ } else {
+ navigate("/search") // fallback
+ }
+ }
+
+ const handleLike = (nav = false) => {
+ if (!user || !token) {
+ openLoginModal('Du måste vara inloggad för att gilla en loppis!')
+ return
+ }
+ if (nav) {
+ if (!isLiked) {
+ toggleLike(loppis._id, user.id, token)
+ }
+ navigate('/profile/favoriter')
+ } else {
+ toggleLike(loppis._id, user.id, token)
+ }
+ }
+
+ if (loading) {
+ return
+ }
+ if (error) {
+ return {error}
+ }
+
+
+ // --- Bild-URLs (NYTT) ---
+ const coverId = loppis.coverImage ?? loppis.images?.[0] ?? null
+
+ // Galleri-tumnaglar (om fler bilder finns)
+ const gallery = (loppis.images || []).slice(1)
+
+ const addressLine = `${loppis.location?.address?.street}, ${loppis.location?.address?.city}`
+
+ const dateToString = (date) => {
+ return `${format(date.date, 'EEEE d MMMM', { locale: sv })}, kl ${date.startTime}-${date.endTime}`
+ }
+
+ return (
+
+
+
+ {/* Back button and like button */}
+
+
+
+ Tillbaka
+
+
+ handleLike(false)} isLiked={isLiked} />
+
+
+ {/* Bilder */}
+
+ {/* Hero image with loader */}
+
+ {!heroLoaded && (
+
+ )}
+ {coverId ? (
+
setHeroLoaded(true)}
+ />
+ ) : (
+
+ Ingen bild
+
+ )}
+
+ {/* gallery with loaders */}
+ {gallery.length > 0 && (
+
+ {gallery.map((image, index) => (
+
+ {!galleryLoaded.includes(index) && (
+
+ )}
+
+ setGalleryLoaded((prev) => [...prev, index])
+ }
+ />
+
+ ))}
+
+ )}
+
+
+ {/* Titel, plats och kategorier */}
+
+
+
+
{loppis.title}
+
{loppis.location?.address?.city}
+
+ {/* If user has granted location - show distance to loppis */}
+ {distance && (
+
+
+
+ {distance < 1
+ ? `${Math.round(distance * 1000)} m bort`
+ : `${distance.toFixed(1)} km bort`}
+
+
+ )}
+
+ {/* Kategorier */}
+
+ {loppis.categories?.map((category, index) => {
+ return
+ })}
+
+
+
+ {/* divider */}
+
+
+ {/* Info och plats */}
+
+ {/* info */}
+
+ {/* Beskrivning */}
+ {loppis.description && (
+
+
Om denna loppis
+
{loppis.description}
+
+ )}
+
+ {/* Detaljer */}
+
+
Detaljer
+ {/* Öppettider */}
+
+ {loppis.dates.map((date, idx) =>
+ )}
+
+ {/* Adress */}
+
+
+
+
+
+ {/* Plats */}
+
+
Plats
+ {/* Karta med pin */}
+
+
+
+
+ {/* divider */}
+
+
+ {/* Links */}
+
+
+
+
+
+
+
+ )
+}
+
+export default LoppisInfo
\ No newline at end of file
diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx
new file mode 100644
index 0000000000..5d2a39794f
--- /dev/null
+++ b/frontend/src/pages/NotFound.jsx
@@ -0,0 +1,66 @@
+import { NavLink, useNavigate, useLocation } from "react-router-dom"
+import { useEffect } from "react"
+
+const NotFound = () => {
+ const { pathname } = useLocation()
+
+ useEffect(() => {
+ window.scrollTo({ top: 0, behavior: "smooth" })
+ }, [])
+
+
+ const colorStyles = 'bg-white border-2 border-button text-button-text hover:bg-button-hover hover:text-button-text-hover rounded-4xl px-6 py-3 font-semibold shadow-md hover:shadow-xl'
+
+
+ return (
+
+
+
+ 404 - Sidan kunde inte hittas
+
+
+
+ Hoppsan! Den här sidan finns inte.
+
+
+ Adressen {pathname} {" "}
+ verkar vara felstavad eller inte längre tillgänglig.
+
+
+
+
+
+ Till startsidan
+
+
+
+ Upptäck på kartan
+
+
+
+ Skapa Loppis
+
+
+
+
+
+
+ )
+}
+
+export default NotFound
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
new file mode 100644
index 0000000000..c43115c78b
--- /dev/null
+++ b/frontend/src/pages/Profile.jsx
@@ -0,0 +1,124 @@
+import { useEffect, useMemo } from 'react'
+import { Link, useParams, useNavigate } from 'react-router-dom'
+import { CirclePlus, Gem, Heart } from 'lucide-react'
+import useAuthStore from "../stores/useAuthStore"
+import useModalStore from '../stores/useModalStore'
+import MyFavorites from "../sections/MyFavorites"
+import MyLoppis from "../sections/MyLoppis"
+import CardLink from "../components/CardLink"
+import Button from '../components/Button'
+import ProfileLayout from './ProfileLayout'
+import bgDefault from "../assets/botanical-3.jpg"
+import bgFav from "../assets/drawing.jpg"
+import bgLoppis from "../assets/circle.jpg"
+import defaultImg from "../assets/default-profile.png"
+
+
+const Profile = () => {
+ const { user, logout, token } = useAuthStore()
+ const { tab } = useParams()
+ const navigate = useNavigate()
+
+
+ // 1) Konfiguration: tab → titel + render-funktion
+ const tabs = useMemo(() => ({
+ loppisar: {
+ title: "Mina Loppisar",
+ render: () => ,
+ },
+ favoriter: {
+ title: "Mina favoriter",
+ render: () => ,
+ },
+ }), [])
+
+ const current = tab ? tabs[tab] : null
+
+ const bgMap = {
+ default: bgDefault,
+ favoriter: bgFav,
+ loppisar: bgLoppis,
+ }
+
+ const bgUrl = current ? (bgMap[tab] || bgMap.default) : bgMap.default
+
+ // (valfritt) Uppdatera dokumenttitel när vy byts
+ useEffect(() => {
+ if (!current) return
+ document.title = `${current.title} · Runt Hörnet`
+ }, [current])
+
+
+ // 2) Om tab finns → rendera “Rubrik + innehåll”
+ if (current) {
+ return (
+ navigate("/profile")}
+
+ >
+ {current.render()}
+
+ )
+ }
+
+ const handleLogout = () => {
+ navigate('/')
+ logout()
+ }
+
+ return (
+
+ Profil
+
+
+
+
+
{user.firstName}
+
+
+
+
+
+ Lägg till loppis
+
+
+
+
+
+
+
+
Profilval
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default Profile
\ No newline at end of file
diff --git a/frontend/src/pages/ProfileLayout.jsx b/frontend/src/pages/ProfileLayout.jsx
new file mode 100644
index 0000000000..750d302ba0
--- /dev/null
+++ b/frontend/src/pages/ProfileLayout.jsx
@@ -0,0 +1,48 @@
+import { ChevronLeft } from "lucide-react"
+
+const ProfileLayout = ({
+ title,
+ bgUrl,
+ showBack = false,
+ onBack,
+ children,
+}) => {
+
+ return (
+
+
+
+
+ {showBack && (
+
+
+ Till profil
+
+ )}
+
+ {title && (
+ <>
+
{title}
+
+ >
+ )}
+
+ {children}
+
+
+
+ )
+}
+
+export default ProfileLayout
diff --git a/frontend/src/pages/ProtectedPage.jsx b/frontend/src/pages/ProtectedPage.jsx
new file mode 100644
index 0000000000..97b2e08449
--- /dev/null
+++ b/frontend/src/pages/ProtectedPage.jsx
@@ -0,0 +1,23 @@
+// src/pages/ProtectedPage.jsx
+import { useEffect, useState } from 'react'
+import { Navigate } from 'react-router-dom'
+import useAuthStore from '../stores/useAuthStore'
+
+const ProtectedPage = ({ children, message = 'Du behöver vara inloggad.' }) => {
+ // Läs bara det du behöver via selector
+ const user = useAuthStore(s => s.user)
+
+ // Vänta på rehydrering från zustand/persist (viktigt vid direkt-URL)
+ const [hydrated, setHydrated] = useState(() => useAuthStore.persist?.hasHydrated?.() ?? true)
+ useEffect(() => {
+ const unsub = useAuthStore.persist?.onFinishHydration?.(() => setHydrated(true))
+ return () => unsub?.()
+ }, [])
+
+ if (!hydrated) return null // eller en liten skeleton/spinner
+
+ if (!user) return
+ return children
+}
+
+export default ProtectedPage
diff --git a/frontend/src/pages/Search.jsx b/frontend/src/pages/Search.jsx
new file mode 100644
index 0000000000..269c43800a
--- /dev/null
+++ b/frontend/src/pages/Search.jsx
@@ -0,0 +1,428 @@
+import { useEffect, useState } from "react"
+import { useMediaQuery } from 'react-responsive'
+import { useSearchParams } from "react-router-dom"
+import { Map, LocateFixed, List, Funnel, X } from "lucide-react"
+import FocusLock from "react-focus-lock"
+import useGeoStore from '../stores/useGeoStore'
+import SearchFilters from "../sections/search/SearchFilters"
+import ListView from "../sections/search/ListView"
+import MapView from "../sections/search/MapView"
+import SearchBar from '../components/SearchBar'
+import Button from "../components/Button"
+import FilterTag from '../components/FilterTag'
+import NoResults from '../components/NoResults'
+import ErrorMessage from "../components/ErrorMessage"
+import { getLoppisList } from '../services/loppisApi'
+import { geocodeCity } from '../services/geocodingApi'
+
+const Search = () => {
+ // fetch states
+ const [searchParams, setSearchParams] = useSearchParams() // get searchParams from React Router’s useSearchParams
+ // derive query object directly from searchParams
+ const query = {
+ city: searchParams.get("city") || "",
+ date: searchParams.get("date") || "all",
+ categories: searchParams.getAll("category"),
+ }
+ const [cityInput, setCityInput] = useState(query.city)
+ // överst i Search-komponenten:
+ const [cityStatus, setCityStatus] = useState('idle') // 'idle' | 'valid' | 'not_found'
+ const [cityError, setCityError] = useState("") // “city not found”
+
+ const [loppisList, setLoppisList] = useState([])
+
+ // map states
+ const centerDefault = [58.5, 15.0] // mid Sweden
+ const zoomDefault = (6) // default show southern/mid Sweden
+ const [mapCenter, setMapCenter] = useState(centerDefault)
+ const [zoom, SetZoom] = useState(zoomDefault)
+ const [centerBy, setCenterBy] = useState('city')
+ // Geo store
+ const location = useGeoStore(s => s.location)
+ const geoStatus = useGeoStore(s => s.status)
+ const geoError = useGeoStore(s => s.error)
+ const requestLocation = useGeoStore(s => s.requestLocation)
+
+ // layout states
+ const [view, setView] = useState("map") //"map" or "list" for mobile, or "desktop"
+ const [showFilters, setShowFilters] = useState(false) // hide search filters by default
+ const isSmallMobile = useMediaQuery({ query: '(max-width: 480px)' })
+ const isMobile = useMediaQuery({ query: '(max-width: 1023px)' })
+
+ // ux states
+ const [isSearching, setIsSearching] = useState(false)
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ // set layout when screen size changes
+ useEffect(() => {
+ if (isMobile) {
+ // moblie - hide search filters and set view to map
+ setShowFilters(false)
+ setView('map')
+ } else {
+ // desktop - show search filters and set view to desktop
+ setShowFilters(true)
+ setView('desktop')
+ }
+ }, [isMobile])
+
+ // fetch loppis list when searchParams change
+ useEffect(() => {
+ const fetchLoppisList = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await getLoppisList(searchParams.toString())
+ const results = data.data || []
+ if (results.length === 0) {
+ setError("Inga loppisar hittades för den här sökningen")
+ setLoppisList([])
+ }
+ setLoppisList(results)
+ } catch (err) {
+ console.error('Failed to fetch loppis data:', err)
+ setError(err.message || 'Kunde inte hämta loppisdata')
+ setLoppisList([])
+ } finally {
+ setLoading(false)
+ }
+ }
+ fetchLoppisList()
+ }, [searchParams])
+
+ // ----- helpers to update searchParams and URL -----
+ const updateCity = (city) => {
+ const newParams = new URLSearchParams(searchParams)
+ if (city) {
+ newParams.set("city", city)
+ } else {
+ newParams.delete("city")
+ }
+ setSearchParams(newParams)
+ }
+
+ const updateDate = (dateId) => {
+ const newParams = new URLSearchParams(searchParams)
+ if (dateId && dateId !== "all") {
+ newParams.set("date", dateId)
+ } else {
+ newParams.delete("date")
+ }
+ setSearchParams(newParams)
+ }
+
+ const addCategory = (category) => {
+ const newParams = new URLSearchParams(searchParams)
+ newParams.append("category", category)
+ setSearchParams(newParams)
+ }
+
+ const removeCategory = (category) => {
+ const newParams = new URLSearchParams(searchParams)
+ const remaining = newParams.getAll("category").filter((cat) => cat !== category)
+ newParams.delete("category") // clear old categories
+ remaining.forEach((cat) => newParams.append("category", cat)) // re-add remaining
+ setSearchParams(newParams)
+ }
+
+ const resetFilters = () => {
+ setSearchParams(new URLSearchParams())
+ setCityInput('')
+ }
+
+ // helper to get date labels from id
+ const getDateLabel = (id) => {
+ const dateOptions = [
+ { id: 'all', label: 'Visa alla' },
+ { id: 'today', label: 'Idag' },
+ { id: 'tomorrow', label: 'Imorgon' },
+ { id: 'weekend', label: 'I helgen' },
+ { id: 'next_week', label: 'Nästa vecka' },
+ ]
+ return dateOptions.find((opt) => opt.id === id)?.label || id
+ }
+
+ // update map center on map from query
+ const updateMapCenter = async (city) => {
+ try {
+ setIsSearching(true)
+ setCityError("")
+ setCityStatus('idle')
+
+ const { lat, lon } = await geocodeCity(city)
+ setMapCenter([parseFloat(lat), parseFloat(lon)]) // triggers MapView.flyTo via props
+ setCenterBy('city')
+ SetZoom(12)
+ setCityStatus('valid')
+
+ } catch (err) {
+ setCityStatus('not_found')
+ setCityError(`Hittade ingen plats som "${city}".`)
+ // återställ kartan
+ setMapCenter(centerDefault)
+ SetZoom(zoomDefault)
+
+
+ } finally {
+ setIsSearching(false)
+ }
+ }
+
+ // useEffect to update map center from search params
+ useEffect(() => {
+ if (query.city) {
+ // geocode new city
+ updateMapCenter(query.city.trim())
+ } else {
+ if (centerBy !== "city") return // respect user override
+ // reset to default
+ setMapCenter(centerDefault)
+ SetZoom(zoomDefault)
+ }
+ }, [query.city, centerBy])
+
+ // handle form search
+ const handleSearch = (e) => {
+ e.preventDefault()
+ updateCity(cityInput.trim())
+ if (isMobile) {
+ setShowFilters(false)
+ }
+ }
+
+ // toggle view for mobile
+ const toggleView = () => {
+ if (view === 'map') {
+ setView('list')
+ } else {
+ setView('map')
+ }
+ }
+
+ // toogle show filters
+ const toggleShowFilters = () => {
+ setShowFilters(prev => !prev)
+ }
+
+ // Ett “effektivt” center som beror på centerBy
+ const effectiveCenter =
+ centerBy === 'user' && location
+ ? [location.lat, location.lng]
+ : mapCenter
+
+ // När MapView (eller kartans flytande ikon) vill hämta plats:
+ const handleRequestLocation = async () => {
+ await requestLocation()
+ setCenterBy('user') // börja styra av användarens position
+ SetZoom(14)
+ }
+
+
+ return (
+
+
+
Sök loppisar
+
+ {/* Search filters */}
+
+ {/* Mobile */}
+ {isMobile && showFilters && (
+
+
+
+
+
+
+
+ )}
+ {/* Desktop */}
+ {!isMobile &&
+
+ }
+
+
+
+ {/* Active filters + toggle buttons */}
+
+ {/* Left side: filters + optional search bar */}
+
+ {/* show search field if filters not open */}
+ {(isMobile && !showFilters) &&
+
+ setCityInput(e.target.value)} />
+
+ }
+ {/* Active filters */}
+
+ {query.city &&
+ {
+ setCityInput('')
+ updateCity('')
+ }} />
+ }
+ {query.date !== "all" &&
+ updateDate("all")} />
+ }
+ {query.categories.map((category) => (
+ removeCategory(category)}
+ />
+ ))}
+ {/* reset filters button */}
+ {(query.date !== "all" || query.categories.length > 0 || query.city) && (
+ resetFilters()}
+ />
+ )}
+
+
+
+ {/* Right side: Toggle buttons */}
+
+ {(!isMobile || view === 'map') &&
+
+ }
+ {isMobile && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {/** 👇 INSERT THIS BLOCK RIGHT HERE (after the toolbar, before Map/List) */}
+ {cityStatus === 'not_found' && cityError && (
+
+
+ {cityError}
+ {
+ setCityInput('')
+ updateCity('')
+ setCityStatus('idle')
+ setCityError('')
+ }}
+ >
+ Visa alla loppisar
+
+
+
+ )}
+ {/** ☝️ END INSERT */}
+
+
+ {/* Map */}
+ {
+ (view === 'map' || view === 'desktop') &&
+ <>
+
+ >
+ }
+
+ {/* List */}
+ {
+ ((view === 'list' || view === 'desktop') && !error) &&
+
+ }
+
+ {/* Error message - if empty list */}
+ {((view === 'list' || view === 'desktop') && error) && (
+
+ )}
+
+
+
+
+ )
+}
+
+
+export default Search
diff --git a/frontend/src/pages/SignUp.jsx b/frontend/src/pages/SignUp.jsx
new file mode 100644
index 0000000000..c6aeb664d5
--- /dev/null
+++ b/frontend/src/pages/SignUp.jsx
@@ -0,0 +1,236 @@
+import { useState, useEffect } from "react"
+import { useNavigate } from 'react-router-dom'
+import useAuthStore from '../stores/useAuthStore'
+import Input from "../components/Input"
+import Button from "../components/Button"
+import image from '../assets/seeds-1.jpg'
+import ErrorMessage from "../components/ErrorMessage"
+import FieldError from "../components/FieldError"
+import { errorMessage } from "../utils/errorMessage"
+
+const SignUp = () => {
+ const { register, isLoading, error, clearError } = useAuthStore()
+ const [formData, setFormData] = useState({
+ name: "",
+ lastName: "",
+ email: "",
+ password: "",
+ })
+
+ const [touched, setTouched] = useState({})
+ const [errors, setErrors] = useState({})
+
+ const navigate = useNavigate()
+
+ // clear error when component mounts
+ useEffect(() => {
+ clearError()
+ }, [])
+
+ // --- validation helpers ---
+ const validateField = (key, val) => {
+ const v = String(val || "").trim()
+ switch (key) {
+ case "name":
+ if (!v) return "Förnamn är obligatoriskt."
+ if (v.length < 2) return "Förnamn måste vara minst 2 tecken."
+ return ""
+ case "lastName":
+ if (!v) return "Efternamn är obligatoriskt."
+ if (v.length < 2) return "Efternamn måste vara minst 2 tecken."
+ return ""
+ case "email": {
+ if (!v) return "E-post är obligatoriskt."
+ const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
+ return ok ? "" : "Ange en giltig e-postadress."
+ }
+ case "password":
+ if (!v) return "Lösenord är obligatoriskt."
+ if (v.length < 8) return "Lösenord måste vara minst 8 tecken."
+ return ""
+ default:
+ return ""
+ }
+ }
+
+ const validateAll = (data) => {
+ const next = {
+ name: validateField("name", data.name),
+ lastName: validateField("lastName", data.lastName),
+ email: validateField("email", data.email),
+ password: validateField("password", data.password),
+ }
+ setErrors(next)
+ return next
+ }
+
+ const onBlur = (key) => (e) => {
+ setTouched((t) => ({ ...t, [key]: true }))
+ setErrors((prev) => ({ ...prev, [key]: validateField(key, e.target.value) }))
+ }
+
+ const onChange = (key) => (e) => {
+ const val = e.target.value
+ setFormData((d) => ({ ...d, [key]: val }))
+ // live-clear an existing error once user fixes it
+ if (touched[key]) {
+ setErrors((prev) => ({ ...prev, [key]: validateField(key, val) }))
+ }
+ }
+
+ // handle form submission
+ const handleSubmit = async (event) => {
+ event.preventDefault()
+
+ const fieldErrors = validateAll(formData)
+ const hasErrors = Object.values(fieldErrors).some(Boolean)
+ if (hasErrors) return
+
+ try {
+ // call register function from auth store
+ await register({
+ firstName: formData.name,
+ lastName: formData.lastName,
+ email: formData.email,
+ password: formData.password,
+ })
+ // clear form data after successful registration
+ setFormData({ name: "", lastName: "", email: "", password: "" })
+ setTouched({})
+ setErrors({})
+ navigate("/")
+
+ } catch (err) {
+ console.error("Något gick fel vid registrering:", err)
+ }
+ }
+
+ const serverErrorText = errorMessage(error)
+
+ return (
+
+
+
+ )
+}
+
+export default SignUp
\ No newline at end of file
diff --git a/frontend/src/sections/Footer.jsx b/frontend/src/sections/Footer.jsx
new file mode 100644
index 0000000000..fc28748d94
--- /dev/null
+++ b/frontend/src/sections/Footer.jsx
@@ -0,0 +1,9 @@
+const Footer = ({ footerText }) => {
+ return (
+
+ )
+}
+
+export default Footer
\ No newline at end of file
diff --git a/frontend/src/sections/MyFavorites.jsx b/frontend/src/sections/MyFavorites.jsx
new file mode 100644
index 0000000000..b1d5c6e0a4
--- /dev/null
+++ b/frontend/src/sections/MyFavorites.jsx
@@ -0,0 +1,32 @@
+import LoppisList from "../components/LoppisList"
+import useAuthStore from "../stores/useAuthStore"
+import useLikesStore from '../stores/useLikesStore'
+
+const MyFavorites = () => {
+ const { user } = useAuthStore()
+ const { likedLoppisData } = useLikesStore()
+
+ if (!user) {
+ return (
+
+ Mina favoriter
+ Du måste vara inloggad för att se dina favorit-loppisar.
+
+ )
+ }
+
+ return (
+
+ Mina favoriter
+
+ {likedLoppisData?.length > 0 ? (
+
+ ) : (
+
Du har inga loppisar än.
+ )}
+
+
+ )
+}
+
+export default MyFavorites
diff --git a/frontend/src/sections/MyLoppis.jsx b/frontend/src/sections/MyLoppis.jsx
new file mode 100644
index 0000000000..c95945efdf
--- /dev/null
+++ b/frontend/src/sections/MyLoppis.jsx
@@ -0,0 +1,133 @@
+import { useState, useEffect } from 'react'
+import { LoaderCircle } from 'lucide-react'
+import LoppisList from "../components/LoppisList"
+import EditModal from "../modals/EditModal"
+import ConfirmDialog from '../components/ConfirmDialog'
+import useAuthStore from "../stores/useAuthStore"
+import { getUserLoppis } from '../services/usersApi'
+import { deleteLoppis } from '../services/loppisApi'
+
+const MyLoppis = () => {
+ const { user, token } = useAuthStore()
+ const [loppisList, setLoppisList] = useState([])
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [emptyMsg, setEmptyMsg] = useState("")
+ const [editingLoppis, setEditingLoppis] = useState(null)
+ const [isEditOpen, setIsEditOpen] = useState(false)
+ const [deletingId, setDeletingId] = useState(null)
+ const [confirmLoppis, setConfirmLoppis] = useState(null)
+
+ useEffect(() => {
+ if (!user || !token) {
+ setError("Du måste vara inloggad för att se dina loppisar.")
+ return
+ }
+
+ const fetchloppisList = async () => {
+ setLoading(true)
+ setError(null)
+ setEmptyMsg("")
+ try {
+ const data = await getUserLoppis(user.id, token)
+ if (!data || data.length === 0) {
+ setEmptyMsg('Du har inga loppisar ännu.')
+ }
+ setLoppisList(data)
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to fetch loppis data:', err)
+ setError(err.message || 'Kunde inte hämta loppisdata')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchloppisList()
+ }, [user])
+
+ //Öppna, stäng editmodal och spara loppis
+ const openEdit = (loppis) => {
+ setEditingLoppis(loppis)
+ setIsEditOpen(true)
+ }
+ const closeEdit = () => {
+ setIsEditOpen(false)
+ setEditingLoppis(null)
+ }
+
+ const applySaved = (updated) => {
+ setLoppisList(prev => prev.map(item => item._id === updated._id ? updated : item))
+ closeEdit()
+ }
+
+ //Ta bort Loppis
+ const handleDelete = async (l) => {
+ if (deletingId) return // blockera parallella deletes
+ setDeletingId(l._id)
+ try {
+ await deleteLoppis(l._id, token)
+ // optimistically remove from list
+ setLoppisList(prev => prev.filter(item => item._id !== l._id))
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to delete loppis: ', err)
+ setError(err.message || 'Kunde inte radera loppis')
+ } finally {
+ setDeletingId(null)
+ setConfirmLoppis(null)
+ }
+ }
+
+ return (
+
+ Mina loppisar
+
+ {error && {error}
}
+ {!error && loppisList?.length > 0 && (
+ setConfirmLoppis(l)}
+ deletingId={deletingId}
+ />
+ )}
+
+ setConfirmLoppis(null)}
+ onConfirm={() => handleDelete(confirmLoppis)}
+ />
+
+
+ {!error && loppisList?.length === 0 && {emptyMsg || "Du har inga loppisar än."}
}
+
+ {loading && (
+
+
+
+ )}
+
+ {/* Edit-popup */}
+
+
+ )
+}
+
+export default MyLoppis
\ No newline at end of file
diff --git a/frontend/src/sections/TopNav.jsx b/frontend/src/sections/TopNav.jsx
new file mode 100644
index 0000000000..02fdda6e5b
--- /dev/null
+++ b/frontend/src/sections/TopNav.jsx
@@ -0,0 +1,189 @@
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import FocusLock from "react-focus-lock"
+import { Squash as Hamburger } from 'hamburger-react'
+import { CircleUserRound, Moon, CirclePlus, Search } from 'lucide-react'
+import Menu from '../components/Menu'
+import MenuItem from '../components/MenuItem'
+import NavItem from '../components/NavItem'
+import MenuLogo from '../components/MenuLogo'
+
+import useAuthStore from '../stores/useAuthStore'
+import useModalStore from '../stores/useModalStore'
+
+const TopNav = () => {
+
+ const [isOpen, setIsOpen] = useState(false)
+ const navigate = useNavigate()
+
+ // auth + modal
+ const { user, token, logout } = useAuthStore()
+ const openLoginModal = useModalStore(s => s.openLoginModal)
+ const isLoggedIn = Boolean(user && token)
+
+ // close menu when an item is clicked
+ const handleMenu = () => {
+ setIsOpen(!isOpen)
+ }
+
+ const handleAuthItem = () => {
+ if (isLoggedIn) {
+ logout()
+ navigate('/') // tillbaka till startsidan efter logout
+ } else {
+ openLoginModal()
+ }
+ setIsOpen(false) // stäng mobilenyn om den är öppen
+ }
+
+ // useEffect to let user close mobile menu on Esc
+ useEffect(() => {
+ const onKeyDown = (e) => {
+ if (e.key === "Escape" && isOpen) {
+ setIsOpen(false)
+ }
+ }
+ window.addEventListener("keydown", onKeyDown)
+ return () => window.removeEventListener("keydown", onKeyDown)
+ }, [isOpen])
+
+
+ const menuItems = [
+ { id: 1, text: 'SÖK LOPPIS', linkTo: '/search' },
+ { id: 2, text: 'LÄGG TILL LOPPIS', linkTo: '/add', requiresAuth: true },
+ { id: 3, text: 'OM OSS', linkTo: '/about' },
+ { id: 4, text: 'KONTAKT', linkTo: '/contact' },
+ { id: 5, text: 'PROFIL', linkTo: '/profile', requiresAuth: true },
+ ]
+
+ return (
+
+ )
+}
+
+export default TopNav
\ No newline at end of file
diff --git a/frontend/src/sections/about/Cta.jsx b/frontend/src/sections/about/Cta.jsx
new file mode 100644
index 0000000000..c36f864911
--- /dev/null
+++ b/frontend/src/sections/about/Cta.jsx
@@ -0,0 +1,35 @@
+import { NavLink } from "react-router-dom"
+import { ArrowRight } from "lucide-react"
+
+const Cta = () => {
+ return (
+
+
+
+
Redo att hitta din nästa favoritpryl?
+
+ Upptäck loppisar i närheten eller skapa din egen – det tar bara någon minut.
+
+
+
+
+
+ Hitta loppisar nära dig
+
+
+ Skapa en annons
+
+
+
+
+
+ )
+}
+
+export default Cta
diff --git a/frontend/src/sections/about/Faq.jsx b/frontend/src/sections/about/Faq.jsx
new file mode 100644
index 0000000000..cddc65854d
--- /dev/null
+++ b/frontend/src/sections/about/Faq.jsx
@@ -0,0 +1,25 @@
+const Faq = ({ q, children }) => (
+
+
+ {q}
+
+ {children}
+
+)
+
+
+const FaqSection = () => {
+ return (
+
+ Vanliga frågor
+
+ Nej, det är gratis att lägga upp en annons just nu.
+ Ja, med ett konto sparas dina favoriter på din profil.
+ Hör av dig via sidan Kontakt så återkommer vi snabbt.
+
+
+ )
+}
+
+
+export default FaqSection
\ No newline at end of file
diff --git a/frontend/src/sections/about/HeroAbout.jsx b/frontend/src/sections/about/HeroAbout.jsx
new file mode 100644
index 0000000000..f6e2d76e5b
--- /dev/null
+++ b/frontend/src/sections/about/HeroAbout.jsx
@@ -0,0 +1,47 @@
+import { ArrowRight } from 'lucide-react'
+import { Link } from 'react-router-dom'
+
+
+const HeroAbout = ({ heroImage, title = 'Runt hörnet - loppis nära dig', lead = 'Vi gör det enkelt och roligt att hitta och dela loppisar i närheten. Vår vision är att fler prylar ska få ett nytt hem och att lokala möten ska uppstå - runt hörnet.' }) => {
+ return (
+
+
+
+
+
+ {heroImage ? (
+
+ ) : (
+
Bild
+ )}
+
+
+
+
+
+
{title}
+
{lead}
+
+
+
+ Upptäck loppisar
+
+
+
+
+
+
+
+
+ )
+}
+
+
+export default HeroAbout
\ No newline at end of file
diff --git a/frontend/src/sections/about/HowItWorks.jsx b/frontend/src/sections/about/HowItWorks.jsx
new file mode 100644
index 0000000000..53b3259518
--- /dev/null
+++ b/frontend/src/sections/about/HowItWorks.jsx
@@ -0,0 +1,23 @@
+const Step = ({ n, children }) => (
+
+)
+
+
+const HowItWorks = () => {
+ return (
+
+ Så funkar det
+
+ Sök på kartan efter loppisar nära dig.
+ Filtrera på datum, kategori och lägg till favoriter.
+ Skapa din egen annons och dela med kvarteret.
+
+
+ )
+}
+
+
+export default HowItWorks
\ No newline at end of file
diff --git a/frontend/src/sections/about/Story.jsx b/frontend/src/sections/about/Story.jsx
new file mode 100644
index 0000000000..567df50b35
--- /dev/null
+++ b/frontend/src/sections/about/Story.jsx
@@ -0,0 +1,36 @@
+import { Sparkles } from 'lucide-react'
+
+
+const Story = () => {
+ return (
+
+
+
+
Hur allt började
+
+ Idén föddes när vi tröttnade på att missa små kvartersloppisar och popups. Vi ville ha en
+ enkel plats som samlar allt – utan krångel. En karta först, tydliga datum och smarta filter.
+ Så byggde vi Runt hörnet.
+
+
+ Sedan starten har vi haft samma mål: göra lokal second hand mer tillgänglig och uppmuntra
+ till återbruk i vardagen.
+
+
+
+
+
Vårt löfte
+
+ Snabbt att hitta – karta och sök i fokus.
+ Schysst upplevelse – enkel design, noll brus.
+ Lokalt först – stöttar initiativ där du bor.
+
+
+
+
+
+ )
+}
+
+
+export default Story
\ No newline at end of file
diff --git a/frontend/src/sections/about/Team.jsx b/frontend/src/sections/about/Team.jsx
new file mode 100644
index 0000000000..2ee383f759
--- /dev/null
+++ b/frontend/src/sections/about/Team.jsx
@@ -0,0 +1,22 @@
+import { ArrowRight } from 'lucide-react'
+import { TeamCard } from '../../components/TeamCard'
+
+
+const Team = ({ team = [] }) => {
+ return (
+
+
+
+ {team.map(m => )}
+
+
+ )
+}
+
+
+export default Team
\ No newline at end of file
diff --git a/frontend/src/sections/about/Values.jsx b/frontend/src/sections/about/Values.jsx
new file mode 100644
index 0000000000..7c75763c51
--- /dev/null
+++ b/frontend/src/sections/about/Values.jsx
@@ -0,0 +1,20 @@
+import { Leaf, HeartHandshake, Recycle } from 'lucide-react'
+import ValueCard from '../../components/ValueCard'
+
+
+const Values = () => {
+ return (
+
+ Våra värderingar
+ Vi tror på cirkulär ekonomi, gemenskap och enkel teknik som gör nytta på riktigt.
+
+ Prylar ska få ett längre liv. Återbruk är både smart och roligt.
+ Loppis handlar om människor. Vi gör det lätt att mötas lokalt.
+ Mindre nykonsumtion, mer omtanke om miljön – ett fynd i taget.
+
+
+ )
+}
+
+
+export default Values
\ No newline at end of file
diff --git a/frontend/src/sections/about/WhyUs.jsx b/frontend/src/sections/about/WhyUs.jsx
new file mode 100644
index 0000000000..c637681ecd
--- /dev/null
+++ b/frontend/src/sections/about/WhyUs.jsx
@@ -0,0 +1,28 @@
+import { MapPin, Sparkles, HeartHandshake } from 'lucide-react'
+
+
+const Feature = ({ icon: Icon, title, children }) => (
+
+)
+
+
+const WhyUs = () => {
+ return (
+
+ Varför Runt hörnet?
+
+ Se vad som händer nära dig – datum, tider och beskrivningar på ett ställe.
+ Skapa en loppis-annons med bilder och position på några minuter.
+ Följ kvarters-vibbarna, gilla favoriter och tipsa grannar.
+
+
+ )
+}
+
+export default WhyUs
\ No newline at end of file
diff --git a/frontend/src/sections/home/CategoryGrid.jsx b/frontend/src/sections/home/CategoryGrid.jsx
new file mode 100644
index 0000000000..1be321822e
--- /dev/null
+++ b/frontend/src/sections/home/CategoryGrid.jsx
@@ -0,0 +1,41 @@
+import { Baby, Lamp, Flower2, Shirt, Sofa, Book, Cat, Tv, CookingPot, Shapes } from 'lucide-react'
+import CardLink from '../../components/CardLink'
+
+const CategoryGrid = () => {
+
+ // TODO: fetch categories from api?
+ const categories = [
+ { label: "Vintage", icon: Lamp },
+ { label: "Barn", icon: Baby },
+ { label: "Trädgård", icon: Flower2 },
+ { label: "Kläder", icon: Shirt },
+ { label: "Möbler", icon: Sofa },
+ { label: "Böcker", icon: Book },
+ { label: "Husdjur", icon: Cat },
+ { label: "Elektronik", icon: Tv },
+ { label: "Kök", icon: CookingPot },
+ { label: "Blandat", icon: Shapes }
+ ]
+
+ return (
+
+ Sök efter kategori
+
+ {categories.map(cat =>
+ )}
+ {/* TODO: ändra bakgrundsfärg på korten? */}
+
+
+ )
+}
+
+export default CategoryGrid
\ No newline at end of file
diff --git a/frontend/src/sections/home/CtaHome.jsx b/frontend/src/sections/home/CtaHome.jsx
new file mode 100644
index 0000000000..9274a23d31
--- /dev/null
+++ b/frontend/src/sections/home/CtaHome.jsx
@@ -0,0 +1,30 @@
+import { useNavigate } from 'react-router-dom'
+import useAuthStore from '../../stores/useAuthStore'
+import useModalStore from '../../stores/useModalStore'
+import Button from '../../components/Button'
+
+const CtaHome = () => {
+ const { user } = useAuthStore()
+ const { openLoginModal } = useModalStore()
+ const navigate = useNavigate()
+
+ const handleAdd = () => {
+ if (!user) {
+ openLoginModal('Du måste vara inloggad för att lägga till en loppis!')
+ return
+ }
+ navigate("/add")
+ }
+
+ return (
+
+
+
Har du saker att sälja?
+
Lägg upp din egen loppis och nå ut till fler loppisälskare!
+
handleAdd()} active={true} classNames='mx-auto mt-6 py-3 px-5' />
+
+
+ )
+}
+
+export default CtaHome
diff --git a/frontend/src/sections/home/HeroSearch.jsx b/frontend/src/sections/home/HeroSearch.jsx
new file mode 100644
index 0000000000..950bcbf136
--- /dev/null
+++ b/frontend/src/sections/home/HeroSearch.jsx
@@ -0,0 +1,38 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import SearchBar from '../../components/SearchBar'
+import heroImage from '../../assets/monstera.jpg'
+
+const HeroSearch = () => {
+ const [city, setCity] = useState('')
+ const navigate = useNavigate()
+
+ const handleSearch = (e) => {
+ e.preventDefault()
+ if (!city.trim()) return
+ navigate(`/search?city=${encodeURIComponent(city.trim())}`)
+ }
+
+ return (
+
+
+ Hitta loppis nära dig
+
+
+ setCity(e.target.value)} />
+ {/* TODO: Visa loppisar nära mig */}
+ {/* Knapp - Visa loppisar nära mig */}
+ {/* Gör en current location fetch */}
+ {/* Skicka koordinaterna till söksidan via queryparams */}
+
+
+ )
+}
+
+export default HeroSearch
\ No newline at end of file
diff --git a/frontend/src/sections/home/PopularCarousel.jsx b/frontend/src/sections/home/PopularCarousel.jsx
new file mode 100644
index 0000000000..ed108c6640
--- /dev/null
+++ b/frontend/src/sections/home/PopularCarousel.jsx
@@ -0,0 +1,154 @@
+import { useState, useEffect } from 'react'
+import { useKeenSlider } from 'keen-slider/react'
+import 'keen-slider/keen-slider.min.css'
+import { ChevronLeft, ChevronRight, LoaderCircle } from "lucide-react"
+import CarouselCard from '../../components/CarouselCard'
+import { getPopularLoppis } from '../../services/loppisApi'
+
+const PopularCarousel = () => {
+ const [loppisList, setLoppisList] = useState([])
+ const [currentSlide, setCurrentSlide] = useState(0)
+ const [loading, setLoading] = useState()
+ const [error, setError] = useState()
+
+ // fetch popular loppis
+ useEffect(() => {
+ const fetchloppisList = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await getPopularLoppis()
+ setLoppisList(data)
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to fetch loppis data:', err)
+ setError(err.message || 'Kunde inte hämta loppisdata')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchloppisList()
+ }, [])
+
+ const [sliderRef, slider] = useKeenSlider({
+ loop: true,
+ slides: {
+ perView: 1,
+ spacing: 15,
+ },
+ breakpoints: {
+ "(min-width: 640px)": {
+ slides: { perView: 2, spacing: 15 },
+ },
+ "(min-width: 1024px)": {
+ slides: { perView: 3, spacing: 20 },
+ },
+ }
+ })
+
+ // recalculate slider after loppisList is fetched
+ useEffect(() => {
+ if (slider.current) {
+ slider.current.update()
+ slider.current.on("slideChanged", (s) => {
+ setCurrentSlide(s.track.details.rel) // rel = relative index
+ })
+ }
+ }, [loppisList, slider])
+
+ const handleKeyDown = (e) => {
+ if (e.key === "ArrowRight") {
+ slider.current?.next()
+ } else if (e.key === "ArrowLeft") {
+ slider.current?.prev()
+ }
+ }
+
+ return (
+
+ Populära Loppisar
+
+ {/* slider */}
+
+ {loppisList.map((loppis, idx) =>
+
+ )}
+
+
+ {/* Don't display slide arrows during loading or if loppislist is empty*/}
+ {(!loading && loppisList.length !== 0) && (
+ <>
+ {/* left arrow */}
+
slider.current?.prev()}
+ ria-label="Previous slide"
+ title="Previous slide"
+ className='group absolute left-3 top-1/2 -translate-y-1/2 bg-white/70 backdrop-blur-sm p-3 rounded-full shadow-md cursor-pointer hover:bg-white transition duration-200'
+ >
+
+
+ {/* right arrow */}
+
slider.current?.next()}
+ aria-label="Next slide"
+ title="Next slide"
+ className="group absolute right-3 top-1/2 -translate-y-1/2 bg-white/70 backdrop-blur-sm p-3 rounded-full shadow-md cursor-pointer hover:bg-white transition duration-200"
+ >
+
+
+ >
+ )}
+
+ {/* pagination dots */}
+
+ {loppisList.map((_, idx) => (
+
+ ))}
+
+
+ {loading && (
+
+ )}
+
+
+
+ {(loppisList.length === 0 && !loading) &&
+ Finns tyvärr inga loppisar att visa just nu.
+ }
+
+
+ )
+}
+
+export default PopularCarousel
\ No newline at end of file
diff --git a/frontend/src/sections/home/Upcoming.jsx b/frontend/src/sections/home/Upcoming.jsx
new file mode 100644
index 0000000000..55150efeb8
--- /dev/null
+++ b/frontend/src/sections/home/Upcoming.jsx
@@ -0,0 +1,89 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import { format } from 'date-fns'
+import { sv } from 'date-fns/locale'
+import { ArrowRight, LoaderCircle } from 'lucide-react'
+import { getUpcomingLoppis } from '../../services/loppisApi'
+
+const Upcoming = () => {
+ const [loppisList, setLoppisList] = useState([])
+ const [loading, setLoading] = useState()
+ const [error, setError] = useState()
+ const [emptyMsg, setEmptyMsg] = useState('')
+
+ // fetch upcoming loppis list
+ useEffect(() => {
+ const fetchloppisList = async () => {
+ setLoading(true)
+ setError(null)
+ setEmptyMsg('')
+ try {
+ const data = await getUpcomingLoppis()
+ if (!data || data.length === 0) {
+ setEmptyMsg('Inga kommande loppisar just nu.')
+ }
+ setLoppisList(data)
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Failed to fetch loppis data:', err)
+ setError(err.message || 'Kunde inte hämta loppisdata')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchloppisList()
+ }, [])
+
+ const dateToString = (date) => {
+ return `${format(date, 'EEE d MMM', { locale: sv })}`
+ }
+
+ return (
+
+ Kommande loppisar
+
+ {loppisList.map((loppis) => (
+
+
+
{loppis.title}
+
+ {dateToString(loppis.nextDate.date)} • {loppis.location.address.city}
+
+
+
+ Visa
+
+
+
+ ))}
+
+ {(loppisList.length === 0 && !loading) &&
+
{emptyMsg || 'Inga kommande loppisar just nu.'}
+ }
+
+ {loading && (
+
+ )}
+
+
+ )
+}
+
+export default Upcoming
\ No newline at end of file
diff --git a/frontend/src/sections/search/ListView.jsx b/frontend/src/sections/search/ListView.jsx
new file mode 100644
index 0000000000..4d4ee4f802
--- /dev/null
+++ b/frontend/src/sections/search/ListView.jsx
@@ -0,0 +1,44 @@
+import { LoaderCircle } from 'lucide-react'
+import LoppisCard from "../../components/LoppisCard";
+
+const ListView = ({ loppisList, loading }) => {
+ return (
+
+ Sökresultat listvy
+
+
+
+
Antal träffar:
+
{loppisList.length} st
+
+
+
Sortera på:
+ {/* TODO: sortering logik och dropdown med alternativ */}
+
Senast tillagda
+
+
+
+
+
+ {loppisList.map(loppis => (
+
+ ))}
+
+
+ {loading && (
+
+ )}
+
+ {/* Page selector */}
+
Page: 1
+
+
+
+ )
+
+}
+
+export default ListView;
\ No newline at end of file
diff --git a/frontend/src/sections/search/MapView.jsx b/frontend/src/sections/search/MapView.jsx
new file mode 100644
index 0000000000..f9864bcfab
--- /dev/null
+++ b/frontend/src/sections/search/MapView.jsx
@@ -0,0 +1,182 @@
+import { MapContainer, TileLayer, Marker, Popup, useMap, ZoomControl, Circle, CircleMarker, Tooltip } from 'react-leaflet'
+import MarkerClusterGroup from 'react-leaflet-cluster'
+
+import LoppisCard from '../../components/LoppisCard'
+import { useEffect, useState } from 'react'
+
+import L from "leaflet"
+import { MapPin, LoaderCircle } from "lucide-react"
+import ReactDOMServer from "react-dom/server"
+import 'leaflet/dist/leaflet.css'
+import useGeoStore from '../../stores/useGeoStore'
+
+const createCustomClusterIcon = (cluster) => {
+ const count = cluster.getChildCount()
+
+ return L.divIcon({
+ html: `
+
+ ${count}
+
+ `,
+ // Ta bort default bakgrund/border från Leaflets divIcon
+ className: "bg-transparent border-0",
+ // Viktigt: detta styr den YTTRE ikonytan (klickyta/placering)
+ iconSize: [36, 36],
+ // Ankra i mitten så den “sitter” på markörens punkt
+ iconAnchor: [18, 18],
+ })
+}
+
+const FlyTo = ({ center, zoom = 11 }) => {
+ const map = useMap()
+ useEffect(() => {
+ if (center && Array.isArray(center) && center.length === 2) {
+ map.flyTo(center, zoom, { duration: 1.0 })
+ }
+ }, [center, zoom, map])
+ return null
+}
+
+function UserLocationLayer() {
+ const loc = useGeoStore(s => s.location)
+ if (!loc) return null
+
+ const { lat, lng, accuracy } = loc
+ const acc = Number.isFinite(accuracy) ? Math.max(accuracy, 25) : 50
+
+ return (
+ <>
+ {/* Accuracy-cirkel */}
+
+ {/* Blå prick */}
+
+
+ Du är här
+
+
+ >
+ )
+}
+
+// Liten wrapper som kan stänga aktuell popup
+const PopupCard = ({ loppis }) => {
+ const map = useMap()
+ return (
+
+ map.closePopup()}
+ />
+
+ )
+}
+
+const MapView = ({
+ loppisList,
+ center,
+ zoom,
+ loading
+}) => {
+
+ const [isMobile, setIsMobile] = useState(() => window.innerWidth < 600)
+
+ useEffect(() => {
+ const onResize = () => setIsMobile(window.innerWidth < 768)
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [])
+
+ // Create a Leaflet divIcon with Lucide SVG
+ const markerIcon = L.divIcon({
+ html: ReactDOMServer.renderToString(
+
+ ),
+ className: "", // Remove default Leaflet styles
+ iconSize: [32, 32],
+ iconAnchor: [16, 16], // Adjust so the "point" is at the right place
+ })
+
+ return (
+
+ Sökresultat kartvy
+
+
+
+ {/* Leaflets zoomkontroll – nere till vänster */}
+
+
+ {/* Imperatively move the map when `center` changes */}
+
+
+ {/* User location layer */}
+
+
+ {/* Custom cluster group with custom icon */}
+
+
+
+ {/* Loop through loppisList and create markers */}
+ {loppisList.map(loppis => (
+
+
+
+
+
+ ))}
+
+
+ {loading && (
+
+ )}
+
+ )
+}
+
+export default MapView
\ No newline at end of file
diff --git a/frontend/src/sections/search/SearchFilters.jsx b/frontend/src/sections/search/SearchFilters.jsx
new file mode 100644
index 0000000000..643dcd37bb
--- /dev/null
+++ b/frontend/src/sections/search/SearchFilters.jsx
@@ -0,0 +1,144 @@
+import { useState, useEffect } from "react"
+import { useSearchParams } from "react-router-dom"
+import SearchBar from "../../components/SearchBar"
+import Button from "../../components/Button"
+import FilterOption from "../../components/FilterOption"
+import { getLoppisCategories } from '../../services/loppisApi'
+
+const SearchFilters = ({ cityInput, setCityInput, onSearch }) => {
+ const [params, setParams] = useSearchParams()
+ const [categoryOptions, setCategoryOptions] = useState()
+ const dateOptions = [
+ { id: 'all', label: 'Visa alla' },
+ { id: 'today', label: 'Idag' },
+ { id: 'tomorrow', label: 'Imorgon' },
+ { id: 'weekend', label: 'I helgen' },
+ { id: 'next_week', label: 'Nästa vecka' },
+ ]
+
+ // read from URL
+ const selectedCity = cityInput
+ const selectedDate = params.get("date") || "all"
+ const selectedCategories = params.getAll("category")
+
+ // fetch category options
+ useEffect(() => {
+ const fetchCategories = async () => {
+ try {
+ const categories = await getLoppisCategories()
+ setCategoryOptions(categories)
+ } catch (err) {
+ // --------------------TODO: handle error appropriately
+ console.error('Error fetching categories:', err)
+ setCategoryOptions([])
+ }
+ }
+ fetchCategories()
+ }, [])
+
+ // generic onChange helper to update params
+ const updateParam = (key, value) => {
+ const newParams = new URLSearchParams(params)
+
+ if (!value || value === "all") {
+ newParams.delete(key)
+ } else {
+ newParams.set(key, value)
+ }
+
+ setParams(newParams)
+ }
+
+ // toggle category
+ const toggleCategory = (category) => {
+ const newParams = new URLSearchParams(params)
+ const categories = newParams.getAll("category")
+
+ if (categories.includes(category)) {
+ // remove
+ const updated = categories.filter((c) => c !== category)
+ newParams.delete("category")
+ updated.forEach((c) => newParams.append("category", c))
+ } else {
+ // add
+ newParams.append("category", category)
+ }
+
+ setParams(newParams)
+ }
+
+ return (
+
+
+ Sökfilter
+
+
+
+
+ {/* Search on city */}
+ setCityInput(e.target.value)}
+ />
+
+ {/* Opening hours */}
+
+ Öppettider
+
+ {dateOptions.map((option) => {
+ return (
+ updateParam("date", e.target.value)}
+ />
+ )
+ })}
+
+
+
+ {/* Categories */}
+
+ Kategorier
+
+ {categoryOptions?.map((option) => {
+ return (
+ toggleCategory(option)}
+ />
+ )
+ })}
+
+
+
+ {/* Submit button */}
+
+
+
+
+ )
+}
+
+export default SearchFilters
\ No newline at end of file
diff --git a/frontend/src/services/authApi.js b/frontend/src/services/authApi.js
new file mode 100644
index 0000000000..fdd830efeb
--- /dev/null
+++ b/frontend/src/services/authApi.js
@@ -0,0 +1,32 @@
+const API_URL = import.meta.env.VITE_API_URL
+const url = `${API_URL}/users`
+
+// API call to register a new user
+export const registerUser = async (userData) => {
+ const response = await fetch(`${url}/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(userData)
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Register failed')
+ }
+ const data = await response.json()
+ return data.response || {} // returns user data {id, firstName, accessToken}}
+}
+
+// API call to login a user
+export const loginUser = async (credentials) => {
+ const response = await fetch(`${url}/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(credentials)
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Login failed')
+ }
+ const data = await response.json()
+ return data.response || {} // returns user data {id, firstName, accessToken}
+}
diff --git a/frontend/src/services/geocodingApi.js b/frontend/src/services/geocodingApi.js
new file mode 100644
index 0000000000..b2d9eea29e
--- /dev/null
+++ b/frontend/src/services/geocodingApi.js
@@ -0,0 +1,28 @@
+const API_URL = import.meta.env.VITE_API_URL
+const url = `${API_URL}/api/geocode`
+
+export const geocodeCity = async (city) => {
+ const response = await fetch(`${url}?q=${encodeURIComponent(city)}`)
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to geocode city')
+ }
+ const data = await response.json()
+ if (!data || data.length === 0) {
+ throw new Error('Inga träffar')
+ }
+ return data[0] // returns object with lat, lon, and adress details
+}
+
+export const reverseGeocode = async (lat, lon) => {
+ const response = await fetch(`${url}/reverse?lat=${lat}&lon=${lon}`)
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to reverse geocode coordinates')
+ }
+ const data = await response.json()
+ if (!data || data.length === 0) {
+ throw new Error('Inga träffar')
+ }
+ return data // returns object with address details
+}
\ No newline at end of file
diff --git a/frontend/src/services/loppisApi.js b/frontend/src/services/loppisApi.js
new file mode 100644
index 0000000000..56cb7c50b9
--- /dev/null
+++ b/frontend/src/services/loppisApi.js
@@ -0,0 +1,145 @@
+const API_URL = import.meta.env.VITE_API_URL
+const url = `${API_URL}/loppis`
+
+// Valfri hjälpare om du vill bygga FormData någon annanstans
+export const buildLoppisFormData = (dataObj, files = []) => {
+ const fd = new FormData()
+ fd.append('data', JSON.stringify(dataObj))
+ for (const file of files) fd.append('images', file)
+ return fd
+}
+
+// Intern hjälpare: skickar FormData oförändrat, annars JSON
+const makeRequest = (url, method, payload, token) => {
+ const opts = { method, headers: { Authorization: token } }
+
+ if (payload instanceof FormData) {
+ // Viktigt: sätt INTE Content-Type själv (boundary sätts automatiskt)
+ opts.body = payload
+ } else {
+ opts.headers['Content-Type'] = 'application/json'
+ opts.body = JSON.stringify(payload)
+ }
+
+ return fetch(url, opts)
+}
+
+// fetch loppis list with optional filters
+export const getLoppisList = async (params) => {
+ const query = !params ? '' : `?${params}`
+ const response = await fetch(`${url}${query}`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error('Tyvärr, vi hittade inga loppisar som matchar din sökning. Testa att justera dina filter eller sök i en annan stad.')
+ } else {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis data')
+ }
+ }
+ const data = await response.json()
+ return data.response || { data: [], totalCount: 0, currentPage: 1, limit: 10 }
+}
+
+// fetch a list with popular loppis (most likes)
+export const getPopularLoppis = async () => {
+ const response = await fetch(`${url}/popular`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ throw new Error('Inga kommande loppisar just nu.')
+ } else {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis data')
+ }
+ }
+ const data = await response.json()
+ return data.response || [] // returns array of loppis
+}
+
+// fetch a list with upcoming loppis (next 5 coming loppis)
+export const getUpcomingLoppis = async () => {
+ const response = await fetch(`${url}/upcoming`)
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis data')
+ }
+ const data = await response.json()
+ return data.response || [] // returns array of loppis
+}
+
+// fetch single loppis by ID
+export const getLoppisById = async (id) => {
+ const response = await fetch(`${url}/${id}`)
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis data')
+ }
+ const data = await response.json()
+ return data.response || {}
+}
+
+// CREATE (accepterar FormData eller JSON — du använder FormData)
+export const createLoppis = async (dataOrFormData, token) => {
+ const response = await makeRequest(url, 'POST', dataOrFormData, token)
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData?.message || 'Failed to create new loppis')
+ }
+ const data = await response.json()
+ return data.response || {}
+}
+
+// UPDATE (accepterar FormData eller JSON — du skickar FormData från LoppisForm)
+export const updateLoppis = async (id, dataOrFormData, token) => {
+ const response = await makeRequest(`${url}/${id}`, 'PATCH', dataOrFormData, token)
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData?.message || 'Failed to edit loppis')
+ }
+ const data = await response.json()
+ return data.response || {}
+}
+
+// delete a loppis by ID
+export const deleteLoppis = async (id, token) => {
+ const response = await fetch(`${url}/${id}`, {
+ method: 'DELETE',
+ headers: {
+ Authorization: token,
+ 'Content-Type': 'application/json',
+ },
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to delete loppis.')
+ }
+ const data = await response.json()
+ return data.response || {}
+}
+
+// like/unlike loppis
+export const toggleLikeLoppis = async (id, token) => {
+ const response = await fetch(`${url}/${id}/like`, {
+ method: 'PATCH',
+ headers: {
+ Authorization: token,
+ 'Content-Type': 'application/json',
+ },
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to like/unlike loppis')
+ }
+ const data = await response.json()
+ return { action: data.response.action, loppis: data.response.data }
+}
+
+// get a list of loppis categories
+export const getLoppisCategories = async () => {
+ const response = await fetch(`${url}/categories`)
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis categories')
+ }
+ const data = await response.json()
+ return data.response || []
+}
diff --git a/frontend/src/services/usersApi.js b/frontend/src/services/usersApi.js
new file mode 100644
index 0000000000..bdcf9c6b7a
--- /dev/null
+++ b/frontend/src/services/usersApi.js
@@ -0,0 +1,57 @@
+const API_URL = import.meta.env.VITE_API_URL
+const url = `${API_URL}/users`
+
+// fetch user profile
+export const getUserProfile = async (id, token) => {
+ const response = await fetch(`${url}/${id}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': token,
+ 'Content-Type': 'application/json'
+ }
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch user profile')
+ }
+ const data = await response.json()
+ return data.response.data || {}
+}
+
+// update user profile
+
+
+// fetch list loppis created by user
+export const getUserLoppis = async (id, token) => {
+ const response = await fetch(`${url}/${id}/loppis`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': token,
+ 'Content-Type': 'application/json'
+ }
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch loppis list')
+ }
+ const data = await response.json()
+ return data.response || []
+}
+
+// fetch list loppis liked by user
+export const getUserLikes = async (id, token) => {
+ const response = await fetch(`${url}/${id}/likes`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': token,
+ 'Content-Type': 'application/json'
+ }
+ })
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData?.message || 'Failed to fetch favorite loppis')
+ }
+ const data = await response.json()
+ return data.response.data || []
+}
+
diff --git a/frontend/src/stores/useAuthStore.js b/frontend/src/stores/useAuthStore.js
new file mode 100644
index 0000000000..1ab1dc7e8f
--- /dev/null
+++ b/frontend/src/stores/useAuthStore.js
@@ -0,0 +1,85 @@
+import { create } from 'zustand'
+import { devtools, persist } from 'zustand/middleware'
+// devtools is used for Redux DevTools tracking
+// persist is used to save to localStorage
+import { loginUser, registerUser } from '../services/authApi.js'
+import useLikesStore from './useLikesStore.js'
+
+const useAuthStore = create(
+ devtools(
+ persist(
+ (set) => ({
+ user: null, // user object with id and FirstName
+ token: null,
+ isLoading: false,
+ error: null,
+
+ login: async (credentials) => {
+ set({ isLoading: true, error: null })
+ try {
+ const currentUser = await loginUser(credentials)
+ set({
+ user: currentUser,
+ token: currentUser.accessToken,
+ isLoading: false,
+ error: null,
+ })
+ // load liked loppis for this user
+ const { loadLikedLoppis } = useLikesStore.getState()
+ await loadLikedLoppis(currentUser.id, currentUser.accessToken)
+ } catch (err) {
+ set({
+ error: err.message || "Inloggningen misslyckades",
+ isLoading: false,
+ })
+ throw err // so component can catch if needed
+ }
+ },
+
+ register: async (userData) => {
+ set({ isLoading: true, error: null })
+ try {
+ const newUser = await registerUser(userData)
+ set({
+ user: newUser,
+ token: newUser.accessToken,
+ isLoading: false,
+ error: null,
+ })
+ } catch (err) {
+ set({
+ error: err.message || "Registreringen misslyckades",
+ isLoading: false,
+ })
+ throw err // so component can catch if needed
+ }
+ },
+
+ logout: () => {
+ set({ user: null, token: null })
+ localStorage.removeItem('token')
+ // clear liked loppis list when logging out
+ const { clearLikes } = useLikesStore.getState()
+ clearLikes()
+ },
+
+ clearError: () => set({ error: null })
+ }),
+ {
+ name: 'auth-storage', // localStorage key
+ onRehydrateStorage: () => (state) => {
+ // called when Zustand rehydrates (restores) the state from localStorage
+ if (state?.user && state?.token) {
+ const { loadLikedLoppis } = useLikesStore.getState()
+ loadLikedLoppis(state.user.id, state.token) //refetch likes
+ }
+ }
+ }
+ ),
+ {
+ name: 'AuthStore', // name shown in Redux DevTools
+ }
+ )
+)
+
+export default useAuthStore
\ No newline at end of file
diff --git a/frontend/src/stores/useErrorStore.js b/frontend/src/stores/useErrorStore.js
new file mode 100644
index 0000000000..292ee1b368
--- /dev/null
+++ b/frontend/src/stores/useErrorStore.js
@@ -0,0 +1,16 @@
+// src/stores/useErrorStore.js
+import { create } from 'zustand'
+
+const useErrorStore = create((set) => ({
+ // Globalt felobjekt (null = inget fel)
+ error: null, // { message: string, type: 'error' | 'warning' | 'info' | 'success', ts: number }
+
+ // Sätt globalt fel
+ setError: (message, type = 'error') =>
+ set({ error: { message, type, ts: Date.now() } }),
+
+ // Rensa fel
+ clearError: () => set({ error: null }),
+}))
+
+export default useErrorStore
diff --git a/frontend/src/stores/useGeoStore.js b/frontend/src/stores/useGeoStore.js
new file mode 100644
index 0000000000..68ad665fe4
--- /dev/null
+++ b/frontend/src/stores/useGeoStore.js
@@ -0,0 +1,125 @@
+import { create } from 'zustand'
+import { subscribeWithSelector } from 'zustand/middleware' //låter dig prenumerera på delar av state (selectors) utan att re‑rendera i onödan. Ger lite extra features runt selektiv subcribing (bra för prestanda).
+
+const DEFAULT_OPTS = {
+ enableHighAccuracy: true, //be enheten ge så exakt position som möjligt (påverkar batteri).
+ timeout: 8000,
+ maximumAge: 0, //acceptera inte cachen; hämta färsk position.
+}
+
+const useGeoStore = create()(
+ subscribeWithSelector((set, get) => ({
+ // state
+ location: null, // { lat, lng, accuracy?, timestamp? }
+ status: 'idle', // 'idle' | 'requesting' | 'granted' | 'denied' | 'error' | 'watching'
+ error: null,
+ supportsGeolocation: typeof window !== 'undefined' && 'geolocation' in navigator, //nabb feature‑detekt – vissa desktop/webview:ar har inte geolokalisering.
+ watchId: null, //id från watchPosition så vi kan stoppa senare.
+
+ /*Först: om funktionaliteten saknas – sätt fel och ge upp. Sätt status till 'requesting' för spinners/feedback. Mixa samman options (default + ev. egna). Permissions API (om det finns): vi kollar om läget redan är 'denied' så UI direkt kan visa rätt copy. (Det här stoppar inte själva geolokaliseringen; det är bara en hint för UI.) */
+ async requestLocation(opts) {
+ if (!get().supportsGeolocation) {
+ set({ status: 'error', error: 'Din webbläsare stödjer inte geolokalisering.' })
+ return
+ }
+
+ set({ status: 'requesting', error: null })
+ const options = { ...DEFAULT_OPTS, ...(opts || {}) }
+
+ try {
+ if (navigator.permissions?.query) {
+ const p = await navigator.permissions.query({ name: 'geolocation' })
+ if (p.state === 'denied') set({ status: 'denied' })
+ }
+ } catch { }
+
+ /*Vi wrappar getCurrentPosition i en Promise så du kan await requestLocation() i UI om du vill. Success: vi plockar ut latitude, longitude, optional accuracy + timestamp, uppdaterar state och sätter status: 'granted'.Error: om felet är PERMISSION_DENIED → status: 'denied' (annars 'error'), samt ett meddelande. Vi anropar alltid resolve() så await inte hänger.*/
+ return new Promise((resolve) => {
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const { latitude, longitude, accuracy } = pos.coords
+ set({
+ location: { lat: latitude, lng: longitude, accuracy, timestamp: pos.timestamp },
+ status: 'granted',
+ error: null,
+ })
+ resolve()
+ },
+ (err) => {
+ const denied = err.code === err.PERMISSION_DENIED
+ set({
+ status: denied ? 'denied' : 'error',
+ error: err.message || (denied ? 'Åtkomst nekad.' : 'Kunde inte hämta platsen.'),
+ })
+ resolve()
+ },
+ options
+ )
+ })
+ },
+
+ /*Startar realtidsuppdatering av positionen. Om redan igång (har watchId) → gör inget. Vid varje positionsuppdatering sätter vi: location till den nya platsen, status till 'watching' (så UI kan visa “följer dig…” t.ex.). Vid fel → samma hantering som ovan. Vi sparar watchId så vi kan stoppa senare. */
+ async startWatching(opts) {
+ if (!get().supportsGeolocation) {
+ set({ status: 'error', error: 'Din webbläsare stödjer inte geolokalisering.' })
+ return
+ }
+ if (get().watchId !== null) return // redan igång
+
+ set({ status: 'requesting', error: null })
+ const options = { ...DEFAULT_OPTS, ...(opts || {}) }
+
+ const id = navigator.geolocation.watchPosition(
+ (pos) => {
+ const { latitude, longitude, accuracy } = pos.coords
+ set({
+ location: { lat: latitude, lng: longitude, accuracy, timestamp: pos.timestamp },
+ status: 'watching',
+ error: null,
+ })
+ },
+ (err) => {
+ const denied = err.code === err.PERMISSION_DENIED
+ set({
+ status: denied ? 'denied' : 'error',
+ error: err.message || (denied ? 'Åtkomst nekad.' : 'Kunde inte hämta platsen.'),
+ })
+ },
+ options
+ )
+
+ set({ watchId: id })
+ },
+
+ stopWatching() {
+ const id = get().watchId
+ if (id !== null) {
+ navigator.geolocation.clearWatch(id)
+ set({ watchId: null, status: get().location ? 'granted' : 'idle' })
+ }
+ },
+
+ clearLocation() {
+ set({ location: null, status: 'idle', error: null })
+ },
+
+ setLocation(loc) {
+ set({ location: loc, status: loc ? 'granted' : 'idle' })
+ },
+ }))
+)
+
+/** Haversine avståndsberäkning i km */
+export function distanceKm(a, b) {
+ const R = 6371
+ const dLat = ((b.lat - a.lat) * Math.PI) / 180
+ const dLng = ((b.lng - a.lng) * Math.PI) / 180
+ const lat1 = (a.lat * Math.PI) / 180
+ const lat2 = (b.lat * Math.PI) / 180
+ const h =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2
+ return 2 * R * Math.asin(Math.sqrt(h))
+}
+
+export default useGeoStore
\ No newline at end of file
diff --git a/frontend/src/stores/useLikesStore.js b/frontend/src/stores/useLikesStore.js
new file mode 100644
index 0000000000..35ce316139
--- /dev/null
+++ b/frontend/src/stores/useLikesStore.js
@@ -0,0 +1,51 @@
+import { create } from 'zustand'
+import { getUserLikes } from '../services/usersApi.js'
+import { toggleLikeLoppis } from '../services/loppisApi.js'
+
+const useLikesStore = create((set, get) => ({
+ likedLoppisData: [],
+ likedLoppisIds: [],
+
+ // load liked loppis on app start or login
+ loadLikedLoppis: async (userId, token) => {
+ try {
+ const data = await getUserLikes(userId, token)
+ const ids = data.map((l) => l._id)
+ set({ likedLoppisData: data, likedLoppisIds: ids })
+ } catch (error) {
+ console.error('Failed to load liked loppis:', error.message)
+ set({ likedLoppisData: [], likedLoppisIds: [] })
+ }
+ },
+
+ // toggle like/unlike for a loppis
+ toggleLike: async (loppisId, userId, token) => {
+ // optimistic update
+ const { likedLoppisIds } = get()
+ const isLiked = likedLoppisIds.includes(loppisId)
+ set({
+ likedLoppisIds: isLiked
+ ? likedLoppisIds.filter(id => id !== loppisId) //unlike
+ : [...likedLoppisIds, loppisId] //like
+ })
+
+ // make API call
+ try {
+ await toggleLikeLoppis(loppisId, token)
+ // reload liked loppis after successful API call to ensure state is consistent
+ get().loadLikedLoppis(userId, token)
+ } catch (error) {
+ console.error('Failed to update like status:', error.message)
+ // rollback optimistic update if API call fails
+ set({
+ likedLoppisIds: isLiked
+ ? [...likedLoppisIds, loppisId] // rollback unlike
+ : likedLoppisIds.filter(id => id !== loppisId) // rollback like
+ })
+ }
+ },
+
+ clearLikes: () => set({ likedLoppisData: [], likedLoppisIds: [] }),
+}))
+
+export default useLikesStore
\ No newline at end of file
diff --git a/frontend/src/stores/useLoppisUpdateStore.js b/frontend/src/stores/useLoppisUpdateStore.js
new file mode 100644
index 0000000000..08058f9d0b
--- /dev/null
+++ b/frontend/src/stores/useLoppisUpdateStore.js
@@ -0,0 +1,11 @@
+import { create } from "zustand"
+
+// Store to manage updating state for loppis items (images)
+
+const useLoppisUpdateStore = create((set) => ({
+ updating: {},
+ setUpdating: (id, flag) =>
+ set((s) => ({ updating: { ...s.updating, [id]: flag } })),
+}))
+
+export default useLoppisUpdateStore
diff --git a/frontend/src/stores/useModalStore.js b/frontend/src/stores/useModalStore.js
new file mode 100644
index 0000000000..02b606c99c
--- /dev/null
+++ b/frontend/src/stores/useModalStore.js
@@ -0,0 +1,12 @@
+import { create } from 'zustand'
+
+const useModalStore = create((set) => ({
+ loginModalOpen: false,
+ loginMessage: '',
+ openLoginModal: (message = '') =>
+ set({ loginModalOpen: true, loginMessage: message }),
+ closeLoginModal: () =>
+ set({ loginModalOpen: false, loginMessage: '' })
+}))
+
+export default useModalStore
\ No newline at end of file
diff --git a/frontend/src/utils/cloudinaryUrl.js b/frontend/src/utils/cloudinaryUrl.js
new file mode 100644
index 0000000000..bd4eed28a7
--- /dev/null
+++ b/frontend/src/utils/cloudinaryUrl.js
@@ -0,0 +1,25 @@
+//Helper för att skapa en Url av bilden samt kunna sätta olika storlekar och till webp format
+
+// src/utils/cloudinaryUrl.js
+const CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME
+
+export const cldUrl = (
+ publicId,
+ { w, h, crop, gravity, quality = 'auto', format = 'auto', dpr } = {}
+) => {
+ if (!publicId) return ''
+ const parts = []
+ parts.push(`q_${quality}`, `f_${format}`)
+ if (w) parts.push(`w_${w}`)
+ if (h) parts.push(`h_${h}`)
+ if (crop) parts.push(`c_${crop}`)
+
+ // gravity bara när det faktiskt används för beskärning
+ const gravityCrops = new Set(['fill', 'crop', 'thumb', 'lfill', 'fill_pad'])
+ if (gravity && crop && gravityCrops.has(crop)) parts.push(`g_${gravity}`)
+
+ if (dpr) parts.push(`dpr_${dpr}`)
+ const transform = parts.join(',')
+ return `https://res.cloudinary.com/${CLOUD_NAME}/image/upload/${transform}/${publicId}`
+}
+
diff --git a/frontend/src/utils/errorMessage.js b/frontend/src/utils/errorMessage.js
new file mode 100644
index 0000000000..9f28f51143
--- /dev/null
+++ b/frontend/src/utils/errorMessage.js
@@ -0,0 +1,11 @@
+// src/utils/errorMessage.js
+export const errorMessage = (err, fallback = "Något gick fel. Försök igen.") => {
+ if (!err) return "" // no error present
+ const msg =
+ err?.response?.data?.message ||
+ err?.data?.message ||
+ err?.message ||
+ err?.error ||
+ err?.errors?.[0]?.message
+ return typeof msg === "string" && msg.trim() ? msg : fallback
+}
diff --git a/frontend/src/utils/imageVariants.js b/frontend/src/utils/imageVariants.js
new file mode 100644
index 0000000000..cfc7b3b8ad
--- /dev/null
+++ b/frontend/src/utils/imageVariants.js
@@ -0,0 +1,16 @@
+import { cldUrl } from './cloudinaryUrl'
+
+// Centrala presets så alla komponenter använder samma storlekar
+export const IMG = {
+ hero: (id) => cldUrl(id, { w: 1200, crop: 'limit' }), // stor, ingen beskärning
+ heroSm: (id) => cldUrl(id, { w: 800, crop: 'limit' }),
+ heroLg: (id) => cldUrl(id, { w: 1600, crop: 'limit' }),
+
+ // Galleri-tumnaglar: små, beskär till samma aspekt
+ thumb: (id) => cldUrl(id, { w: 240, h: 180, crop: 'fill', gravity: 'auto' }),
+ thumb2x: (id) => cldUrl(id, { w: 480, h: 360, crop: 'fill', gravity: 'auto' }),
+
+ // Kort (listor)
+ card: (id) => cldUrl(id, { w: 320, h: 240, crop: 'fill', gravity: 'auto' }),
+ card2x: (id) => cldUrl(id, { w: 640, h: 480, crop: 'fill', gravity: 'auto' }),
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 5a33944a9b..f5f63ee953 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [
+ react(),
+ tailwindcss()
+ ],
})
diff --git a/package.json b/package.json
index 680d190772..a82a04b2e6 100644
--- a/package.json
+++ b/package.json
@@ -3,5 +3,6 @@
"version": "1.0.0",
"scripts": {
"postinstall": "npm install --prefix backend"
- }
+ },
+ "dependencies": {}
}
\ No newline at end of file