├── client ├── src │ ├── index.css │ ├── lib │ │ └── axios.js │ ├── main.jsx │ ├── socket │ │ └── socket.client.js │ ├── store │ │ ├── useUserStore.js │ │ ├── useMessageStore.js │ │ ├── useAuthStore.js │ │ └── useMatchStore.js │ ├── components │ │ ├── SwipeFeedback.jsx │ │ ├── SwipeArea.jsx │ │ ├── LoginForm.jsx │ │ ├── MessageInput.jsx │ │ ├── Sidebar.jsx │ │ ├── Header.jsx │ │ └── SignUpForm.jsx │ ├── App.jsx │ └── pages │ │ ├── AuthPage.jsx │ │ ├── HomePage.jsx │ │ ├── ChatPage.jsx │ │ └── ProfilePage.jsx ├── public │ ├── avatar.png │ ├── male │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── female │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ ├── 9.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ └── 12.jpg │ ├── screenshot-for-readme.png │ └── vite.svg ├── postcss.config.js ├── vite.config.js ├── tailwind.config.js ├── index.html ├── README.md ├── eslint.config.js └── package.json ├── api ├── routes │ ├── userRoutes.js │ ├── messageRoutes.js │ ├── authRoutes.js │ └── matchRoutes.js ├── config │ ├── cloudinary.js │ └── db.js ├── models │ ├── Message.js │ └── User.js ├── socket │ └── socket.server.js ├── middleware │ └── auth.js ├── controllers │ ├── userController.js │ ├── messageController.js │ ├── authController.js │ └── matchController.js ├── server.js └── seeds │ └── user.js ├── .gitignore ├── package.json └── README.md /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /client/public/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/avatar.png -------------------------------------------------------------------------------- /client/public/male/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/1.jpg -------------------------------------------------------------------------------- /client/public/male/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/2.jpg -------------------------------------------------------------------------------- /client/public/male/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/3.jpg -------------------------------------------------------------------------------- /client/public/male/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/4.jpg -------------------------------------------------------------------------------- /client/public/male/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/5.jpg -------------------------------------------------------------------------------- /client/public/male/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/6.jpg -------------------------------------------------------------------------------- /client/public/male/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/7.jpg -------------------------------------------------------------------------------- /client/public/male/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/8.jpg -------------------------------------------------------------------------------- /client/public/male/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/male/9.jpg -------------------------------------------------------------------------------- /client/public/female/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/1.jpg -------------------------------------------------------------------------------- /client/public/female/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/2.jpg -------------------------------------------------------------------------------- /client/public/female/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/3.jpg -------------------------------------------------------------------------------- /client/public/female/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/4.jpg -------------------------------------------------------------------------------- /client/public/female/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/5.jpg -------------------------------------------------------------------------------- /client/public/female/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/6.jpg -------------------------------------------------------------------------------- /client/public/female/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/7.jpg -------------------------------------------------------------------------------- /client/public/female/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/8.jpg -------------------------------------------------------------------------------- /client/public/female/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/9.jpg -------------------------------------------------------------------------------- /client/public/female/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/10.jpg -------------------------------------------------------------------------------- /client/public/female/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/11.jpg -------------------------------------------------------------------------------- /client/public/female/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/female/12.jpg -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/public/screenshot-for-readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/burakorkmez/tinder-clone/HEAD/client/public/screenshot-for-readme.png -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/lib/axios.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const BASE_URL = import.meta.env.MODE === "development" ? "http://localhost:5000/api" : "/api"; 4 | 5 | export const axiosInstance = axios.create({ 6 | baseURL: BASE_URL, 7 | withCredentials: true, 8 | }); 9 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import daisyui from "daisyui"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [daisyui], 10 | }; 11 | -------------------------------------------------------------------------------- /api/routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { protectRoute } from "../middleware/auth.js"; 3 | import { updateProfile } from "../controllers/userController.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.put("/update", protectRoute, updateProfile); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /api/config/cloudinary.js: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from "cloudinary"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config(); 5 | 6 | cloudinary.config({ 7 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 8 | api_key: process.env.CLOUDINARY_API_KEY, 9 | api_secret: process.env.CLOUDINARY_API_SECRET, 10 | }); 11 | 12 | export default cloudinary; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .env 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /client/src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App.jsx"; 4 | import "./index.css"; 5 | 6 | import { BrowserRouter } from "react-router-dom"; 7 | 8 | createRoot(document.getElementById("root")).render( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /api/config/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export const connectDB = async () => { 4 | try { 5 | const conn = await mongoose.connect(process.env.MONGO_URI); 6 | console.log(`MongoDB Connected: ${conn.connection.host}`); 7 | } catch (error) { 8 | console.log("Error connecting to MongoDB: ", error); 9 | process.exit(1); // exit process with failure, 0 for success 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /api/routes/messageRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { protectRoute } from "../middleware/auth.js"; 3 | import { getConversation, sendMessage } from "../controllers/messageController.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.use(protectRoute); 8 | 9 | router.post("/send", sendMessage); 10 | router.get("/conversation/:userId", getConversation); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /api/routes/authRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { signup, login, logout } from "../controllers/authController.js"; 3 | import { protectRoute } from "../middleware/auth.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.post("/signup", signup); 8 | router.post("/login", login); 9 | router.post("/logout", logout); 10 | 11 | router.get("/me", protectRoute, (req, res) => { 12 | res.send({ 13 | success: true, 14 | user: req.user, 15 | }); 16 | }); 17 | 18 | export default router; 19 | -------------------------------------------------------------------------------- /api/models/Message.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const messageSchema = new mongoose.Schema( 4 | { 5 | sender: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: "User", 8 | required: true, 9 | }, 10 | receiver: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: "User", 13 | required: true, 14 | }, 15 | content: { 16 | type: String, 17 | required: true, 18 | }, 19 | }, 20 | { timestamps: true } 21 | ); 22 | 23 | const Message = mongoose.model("Message", messageSchema); 24 | 25 | export default Message; 26 | -------------------------------------------------------------------------------- /api/routes/matchRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { protectRoute } from "../middleware/auth.js"; 3 | import { getMatches, getUserProfiles, swipeLeft, swipeRight } from "../controllers/matchController.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.post("/swipe-right/:likedUserId", protectRoute, swipeRight); 8 | router.post("/swipe-left/:dislikedUserId", protectRoute, swipeLeft); 9 | 10 | router.get("/", protectRoute, getMatches); 11 | router.get("/user-profiles", protectRoute, getUserProfiles); 12 | 13 | export default router; 14 | -------------------------------------------------------------------------------- /client/src/socket/socket.client.js: -------------------------------------------------------------------------------- 1 | import io from "socket.io-client"; 2 | 3 | const SOCKET_URL = import.meta.env.MODE === "development" ? "http://localhost:5000" : "/"; 4 | 5 | let socket = null; 6 | 7 | export const initializeSocket = (userId) => { 8 | if (socket) { 9 | socket.disconnect(); 10 | } 11 | 12 | socket = io(SOCKET_URL, { 13 | auth: { userId }, 14 | }); 15 | }; 16 | 17 | export const getSocket = () => { 18 | if (!socket) { 19 | throw new Error("Socket not initialized"); 20 | } 21 | return socket; 22 | }; 23 | 24 | export const disconnectSocket = () => { 25 | if (socket) { 26 | socket.disconnect(); 27 | socket = null; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/store/useUserStore.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { axiosInstance } from "../lib/axios"; 3 | import toast from "react-hot-toast"; 4 | import { useAuthStore } from "./useAuthStore"; 5 | 6 | export const useUserStore = create((set) => ({ 7 | loading: false, 8 | 9 | updateProfile: async (data) => { 10 | try { 11 | set({ loading: true }); 12 | const res = await axiosInstance.put("/users/update", data); 13 | useAuthStore.getState().setAuthUser(res.data.user); 14 | toast.success("Profile updated successfully"); 15 | } catch (error) { 16 | toast.error(error.response.data.message || "Something went wrong"); 17 | } finally { 18 | set({ loading: false }); 19 | } 20 | }, 21 | })); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tinder-clone", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon api/server.js", 8 | "build": "npm install && npm install --prefix client && npm run build --prefix client", 9 | "start": "node api/server.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "type": "module", 14 | "license": "ISC", 15 | "dependencies": { 16 | "bcryptjs": "^2.4.3", 17 | "cloudinary": "^2.5.0", 18 | "cookie-parser": "^1.4.6", 19 | "cors": "^2.8.5", 20 | "dotenv": "^16.4.5", 21 | "express": "^4.21.0", 22 | "jsonwebtoken": "^9.0.2", 23 | "mongoose": "^8.7.0", 24 | "socket.io": "^4.8.0" 25 | }, 26 | "devDependencies": { 27 | "nodemon": "^3.1.7" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/components/SwipeFeedback.jsx: -------------------------------------------------------------------------------- 1 | import { useMatchStore } from "../store/useMatchStore"; 2 | 3 | const getFeedbackStyle = (swipeFeedback) => { 4 | if (swipeFeedback === "liked") return "text-green-500"; 5 | if (swipeFeedback === "passed") return "text-red-500"; 6 | if (swipeFeedback === "matched") return "text-pink-500"; 7 | return ""; 8 | }; 9 | 10 | const getFeedbackText = (swipeFeedback) => { 11 | if (swipeFeedback === "liked") return "Liked!"; 12 | if (swipeFeedback === "passed") return "Passed"; 13 | if (swipeFeedback === "matched") return "It's a Match!"; 14 | return ""; 15 | }; 16 | 17 | const SwipeFeedback = () => { 18 | const { swipeFeedback } = useMatchStore(); 19 | 20 | return ( 21 |
26 | {getFeedbackText(swipeFeedback)} 27 |
28 | ); 29 | }; 30 | export default SwipeFeedback; 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Tinder Clone ✨

2 | 3 | ![Demo App](/client/public/screenshot-for-readme.png) 4 | 5 | About This Course: 6 | 7 | - 🔐 Authentication System with JWT 8 | - 🛡️ Route Protection 9 | - 👤 User Profile Creation and Updates 10 | - 🖼️ Image Upload for Profiles 11 | - 🔄 Swipe Right/Left Feature 12 | - 💬 Real-time Chat Messaging 13 | - 🔔 Real-time Notifications 14 | - 🤝 Matching Algorithm 15 | - 📱 Responsive Mobile Design 16 | - ⌛ And a lot more... 17 | 18 | ### Setup .env file 19 | 20 | ```bash 21 | PORT=5000 22 | MONGO_URI= 23 | 24 | JWT_SECRET= 25 | 26 | NODE_ENV=development 27 | CLIENT_URL=http://localhost:5173 28 | 29 | CLOUDINARY_API_KEY= 30 | CLOUDINARY_API_SECRET= 31 | CLOUDINARY_CLOUD_NAME= 32 | 33 | ``` 34 | 35 | ### Run this app locally 36 | 37 | - Set `NODE_ENV=production` and build the app 👇 38 | 39 | ```shell 40 | npm run build 41 | ``` 42 | 43 | ### Start the app 44 | 45 | ```shell 46 | npm run start 47 | ``` 48 | -------------------------------------------------------------------------------- /api/socket/socket.server.js: -------------------------------------------------------------------------------- 1 | import { Server } from "socket.io"; 2 | 3 | let io; 4 | 5 | const connectedUsers = new Map(); 6 | // { userId: socketId } 7 | 8 | export const initializeSocket = (httpServer) => { 9 | io = new Server(httpServer, { 10 | cors: { 11 | origin: process.env.CLIENT_URL, 12 | credentials: true, 13 | }, 14 | }); 15 | 16 | io.use((socket, next) => { 17 | const userId = socket.handshake.auth.userId; 18 | if (!userId) return next(new Error("Invalid user ID")); 19 | 20 | socket.userId = userId; 21 | next(); 22 | }); 23 | 24 | io.on("connection", (socket) => { 25 | console.log(`User connected with socket id: ${socket.id}`); 26 | connectedUsers.set(socket.userId, socket.id); 27 | 28 | socket.on("disconnect", () => { 29 | console.log(`User disconnected with socket id: ${socket.id}`); 30 | connectedUsers.delete(socket.userId); 31 | }); 32 | }); 33 | }; 34 | 35 | export const getIO = () => { 36 | if (!io) { 37 | throw new Error("Socket.io not initialized!"); 38 | } 39 | return io; 40 | }; 41 | 42 | export const getConnectedUsers = () => connectedUsers; 43 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import react from "eslint-plugin-react"; 4 | import reactHooks from "eslint-plugin-react-hooks"; 5 | import reactRefresh from "eslint-plugin-react-refresh"; 6 | 7 | export default [ 8 | { ignores: ["dist"] }, 9 | { 10 | files: ["**/*.{js,jsx}"], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: "module", 18 | }, 19 | }, 20 | settings: { react: { version: "18.3" } }, 21 | plugins: { 22 | react, 23 | "react-hooks": reactHooks, 24 | "react-refresh": reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs["jsx-runtime"].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | "react/jsx-no-target-blank": "off", 32 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 33 | "react/prop-types": "off", 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /api/middleware/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import User from "../models/User.js"; 3 | 4 | export const protectRoute = async (req, res, next) => { 5 | try { 6 | const token = req.cookies.jwt; 7 | 8 | if (!token) { 9 | return res.status(401).json({ 10 | success: false, 11 | message: "Not authorized - No token provided", 12 | }); 13 | } 14 | 15 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 16 | 17 | if (!decoded) { 18 | return res.status(401).json({ 19 | success: false, 20 | message: "Not authorized - Invalid token", 21 | }); 22 | } 23 | 24 | const currentUser = await User.findById(decoded.id); 25 | 26 | req.user = currentUser; 27 | 28 | next(); 29 | } catch (error) { 30 | console.log("Error in auth middleware: ", error); 31 | 32 | if (error instanceof jwt.JsonWebTokenError) { 33 | return res.status(401).json({ 34 | success: false, 35 | message: "Not authorized - Invalid token", 36 | }); 37 | } else { 38 | return res.status(500).json({ 39 | success: false, 40 | message: "Internal server error", 41 | }); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /api/controllers/userController.js: -------------------------------------------------------------------------------- 1 | import cloudinary from "../config/cloudinary.js"; 2 | import User from "../models/User.js"; 3 | 4 | export const updateProfile = async (req, res) => { 5 | // image => cloudinary -> image.cloudinary.your => mongodb 6 | 7 | try { 8 | const { image, ...otherData } = req.body; 9 | 10 | let updatedData = otherData; 11 | 12 | if (image) { 13 | // base64 format 14 | if (image.startsWith("data:image")) { 15 | try { 16 | const uploadResponse = await cloudinary.uploader.upload(image); 17 | updatedData.image = uploadResponse.secure_url; 18 | } catch (error) { 19 | console.error("Error uploading image:", uploadError); 20 | 21 | return res.status(400).json({ 22 | success: false, 23 | message: "Error uploading image", 24 | }); 25 | } 26 | } 27 | } 28 | 29 | const updatedUser = await User.findByIdAndUpdate(req.user.id, updatedData, { new: true }); 30 | 31 | res.status(200).json({ 32 | success: true, 33 | user: updatedUser, 34 | }); 35 | } catch (error) { 36 | console.log("Error in updateProfile: ", error); 37 | res.status(500).json({ 38 | success: false, 39 | message: "Internal server error", 40 | }); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@react-spring/web": "^9.7.5", 14 | "axios": "^1.7.7", 15 | "emoji-picker-react": "^4.12.0", 16 | "lucide-react": "^0.447.0", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1", 19 | "react-hot-toast": "^2.4.1", 20 | "react-router-dom": "^6.26.2", 21 | "react-tinder-card": "^1.6.4", 22 | "socket.io-client": "^4.8.0", 23 | "zustand": "^5.0.0-rc.2" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.11.1", 27 | "@types/react": "^18.3.10", 28 | "@types/react-dom": "^18.3.0", 29 | "@vitejs/plugin-react": "^4.3.2", 30 | "autoprefixer": "^10.4.20", 31 | "daisyui": "^4.12.12", 32 | "eslint": "^9.11.1", 33 | "eslint-plugin-react": "^7.37.0", 34 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 35 | "eslint-plugin-react-refresh": "^0.4.12", 36 | "globals": "^15.9.0", 37 | "postcss": "^8.4.47", 38 | "tailwindcss": "^3.4.13", 39 | "vite": "^5.4.8" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Route, Routes } from "react-router-dom"; 2 | 3 | import HomePage from "./pages/HomePage"; 4 | import AuthPage from "./pages/AuthPage"; 5 | import ProfilePage from "./pages/ProfilePage"; 6 | import ChatPage from "./pages/ChatPage"; 7 | import { useAuthStore } from "./store/useAuthStore"; 8 | import { useEffect } from "react"; 9 | import { Toaster } from "react-hot-toast"; 10 | 11 | function App() { 12 | const { checkAuth, authUser, checkingAuth } = useAuthStore(); 13 | 14 | useEffect(() => { 15 | checkAuth(); 16 | }, [checkAuth]); 17 | 18 | if (checkingAuth) return null; 19 | 20 | return ( 21 |
22 | 23 | : } /> 24 | : } /> 25 | : } /> 26 | : } /> 27 | 28 | 29 | 30 |
31 | ); 32 | } 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /client/src/pages/AuthPage.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import LoginForm from "../components/LoginForm"; 4 | import SignUpForm from "../components/SignUpForm"; 5 | 6 | const AuthPage = () => { 7 | const [isLogin, setIsLogin] = useState(true); 8 | 9 | return ( 10 |
15 |
16 |

17 | {isLogin ? "Sign in to Swipe" : "Create a Swipe account"} 18 |

19 | 20 |
21 | {isLogin ? : } 22 | 23 |
24 |

25 | {isLogin ? "New to Swipe?" : "Already have an account?"} 26 |

27 | 28 | 34 |
35 |
36 |
37 |
38 | ); 39 | }; 40 | export default AuthPage; 41 | -------------------------------------------------------------------------------- /client/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/server.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import cookieParser from "cookie-parser"; 4 | import cors from "cors"; 5 | import path from "path"; 6 | import { createServer } from "http"; 7 | 8 | // routes 9 | import authRoutes from "./routes/authRoutes.js"; 10 | import userRoutes from "./routes/userRoutes.js"; 11 | import matchRoutes from "./routes/matchRoutes.js"; 12 | import messageRoutes from "./routes/messageRoutes.js"; 13 | 14 | import { connectDB } from "./config/db.js"; 15 | import { initializeSocket } from "./socket/socket.server.js"; 16 | 17 | dotenv.config(); 18 | 19 | const app = express(); 20 | const httpServer = createServer(app); 21 | const PORT = process.env.PORT || 5000; 22 | 23 | const __dirname = path.resolve(); 24 | 25 | initializeSocket(httpServer); 26 | 27 | app.use(express.json()); 28 | app.use(cookieParser()); 29 | app.use( 30 | cors({ 31 | origin: process.env.CLIENT_URL, 32 | credentials: true, 33 | }) 34 | ); 35 | 36 | app.use("/api/auth", authRoutes); 37 | app.use("/api/users", userRoutes); 38 | app.use("/api/matches", matchRoutes); 39 | app.use("/api/messages", messageRoutes); 40 | 41 | if (process.env.NODE_ENV === "production") { 42 | app.use(express.static(path.join(__dirname, "/client/dist"))); 43 | 44 | app.get("*", (req, res) => { 45 | res.sendFile(path.resolve(__dirname, "client", "dist", "index.html")); 46 | }); 47 | } 48 | 49 | httpServer.listen(PORT, () => { 50 | console.log("Server started at this port:" + PORT); 51 | connectDB(); 52 | }); 53 | -------------------------------------------------------------------------------- /client/src/components/SwipeArea.jsx: -------------------------------------------------------------------------------- 1 | import TinderCard from "react-tinder-card"; 2 | import { useMatchStore } from "../store/useMatchStore"; 3 | 4 | const SwipeArea = () => { 5 | const { userProfiles, swipeRight, swipeLeft } = useMatchStore(); 6 | 7 | const handleSwipe = (dir, user) => { 8 | if (dir === "right") swipeRight(user); 9 | else if (dir === "left") swipeLeft(user); 10 | }; 11 | 12 | return ( 13 |
14 | {userProfiles.map((user) => ( 15 | handleSwipe(dir, user)} 19 | swipeRequirementType='position' 20 | swipeThreshold={100} 21 | preventSwipe={["up", "down"]} 22 | > 23 |
27 |
28 | {user.name} 33 |
34 |
35 |

36 | {user.name}, {user.age} 37 |

38 |

{user.bio}

39 |
40 |
41 |
42 | ))} 43 |
44 | ); 45 | }; 46 | export default SwipeArea; 47 | -------------------------------------------------------------------------------- /api/controllers/messageController.js: -------------------------------------------------------------------------------- 1 | import Message from "../models/Message.js"; 2 | import { getConnectedUsers, getIO } from "../socket/socket.server.js"; 3 | 4 | export const sendMessage = async (req, res) => { 5 | try { 6 | const { content, receiverId } = req.body; 7 | 8 | const newMessage = await Message.create({ 9 | sender: req.user.id, 10 | receiver: receiverId, 11 | content, 12 | }); 13 | 14 | const io = getIO(); 15 | const connectedUsers = getConnectedUsers(); 16 | const receiverSocketId = connectedUsers.get(receiverId); 17 | 18 | if (receiverSocketId) { 19 | io.to(receiverSocketId).emit("newMessage", { 20 | message: newMessage, 21 | }); 22 | } 23 | 24 | res.status(201).json({ 25 | success: true, 26 | message: newMessage, 27 | }); 28 | } catch (error) { 29 | console.log("Error in sendMessage: ", error); 30 | res.status(500).json({ 31 | success: false, 32 | message: "Internal server error", 33 | }); 34 | } 35 | }; 36 | 37 | export const getConversation = async (req, res) => { 38 | const { userId } = req.params; 39 | try { 40 | const messages = await Message.find({ 41 | $or: [ 42 | { sender: req.user._id, receiver: userId }, 43 | { sender: userId, receiver: req.user._id }, 44 | ], 45 | }).sort("createdAt"); 46 | 47 | res.status(200).json({ 48 | success: true, 49 | messages, 50 | }); 51 | } catch (error) { 52 | console.log("Error in getConversation: ", error); 53 | res.status(500).json({ 54 | success: false, 55 | message: "Internal server error", 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /client/src/store/useMessageStore.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { axiosInstance } from "../lib/axios"; 3 | import toast from "react-hot-toast"; 4 | import { getSocket } from "../socket/socket.client"; 5 | import { useAuthStore } from "./useAuthStore"; 6 | 7 | export const useMessageStore = create((set) => ({ 8 | messages: [], 9 | loading: true, 10 | 11 | sendMessage: async (receiverId, content) => { 12 | try { 13 | // mockup a message, show it in the chat immediately 14 | set((state) => ({ 15 | messages: [ 16 | ...state.messages, 17 | { _id: Date.now(), sender: useAuthStore.getState().authUser._id, content }, 18 | ], 19 | })); 20 | const res = await axiosInstance.post("/messages/send", { receiverId, content }); 21 | console.log("message sent", res.data); 22 | } catch (error) { 23 | toast.error(error.response.data.message || "Something went wrong"); 24 | } 25 | }, 26 | 27 | getMessages: async (userId) => { 28 | try { 29 | set({ loading: true }); 30 | const res = await axiosInstance.get(`/messages/conversation/${userId}`); 31 | set({ messages: res.data.messages }); 32 | } catch (error) { 33 | console.log(error); 34 | set({ messages: [] }); 35 | } finally { 36 | set({ loading: false }); 37 | } 38 | }, 39 | 40 | subscribeToMessages: () => { 41 | const socket = getSocket(); 42 | socket.on("newMessage", ({ message }) => { 43 | set((state) => ({ messages: [...state.messages, message] })); 44 | }); 45 | }, 46 | 47 | unsubscribeFromMessages: () => { 48 | const socket = getSocket(); 49 | socket.off("newMessage"); 50 | }, 51 | })); 52 | -------------------------------------------------------------------------------- /api/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | const userSchema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | required: true, 9 | }, 10 | email: { 11 | type: String, 12 | required: true, 13 | unique: true, 14 | }, 15 | password: { 16 | type: String, 17 | required: true, 18 | }, 19 | age: { 20 | type: Number, 21 | required: true, 22 | }, 23 | gender: { 24 | type: String, 25 | required: true, 26 | enum: ["male", "female"], 27 | }, 28 | genderPreference: { 29 | type: String, 30 | required: true, 31 | enum: ["male", "female", "both"], 32 | }, 33 | bio: { type: String, default: "" }, 34 | image: { type: String, default: "" }, 35 | likes: [ 36 | { 37 | type: mongoose.Schema.Types.ObjectId, 38 | ref: "User", 39 | }, 40 | ], 41 | dislikes: [ 42 | { 43 | type: mongoose.Schema.Types.ObjectId, 44 | ref: "User", 45 | }, 46 | ], 47 | matches: [ 48 | { 49 | type: mongoose.Schema.Types.ObjectId, 50 | ref: "User", 51 | }, 52 | ], 53 | }, 54 | { timestamps: true } 55 | ); 56 | 57 | userSchema.pre("save", async function (next) { 58 | // MAKE SURE TO ADD THIS IF CHECK!!! 👇 I forgot to add this in the tutorial 59 | // only hash if password is modified. 60 | if (!this.isModified("password")) { 61 | return next(); 62 | } 63 | 64 | this.password = await bcrypt.hash(this.password, 10); 65 | next(); 66 | }); 67 | 68 | userSchema.methods.matchPassword = async function (enteredPassword) { 69 | return await bcrypt.compare(enteredPassword, this.password); 70 | }; 71 | 72 | const User = mongoose.model("User", userSchema); 73 | 74 | export default User; 75 | -------------------------------------------------------------------------------- /client/src/store/useAuthStore.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { axiosInstance } from "../lib/axios"; 3 | import toast from "react-hot-toast"; 4 | import { disconnectSocket, initializeSocket } from "../socket/socket.client"; 5 | 6 | export const useAuthStore = create((set) => ({ 7 | authUser: null, 8 | checkingAuth: true, 9 | loading: false, 10 | 11 | signup: async (signupData) => { 12 | try { 13 | set({ loading: true }); 14 | const res = await axiosInstance.post("/auth/signup", signupData); 15 | set({ authUser: res.data.user }); 16 | initializeSocket(res.data.user._id); 17 | 18 | toast.success("Account created successfully"); 19 | } catch (error) { 20 | toast.error(error.response.data.message || "Something went wrong"); 21 | } finally { 22 | set({ loading: false }); 23 | } 24 | }, 25 | login: async (loginData) => { 26 | try { 27 | set({ loading: true }); 28 | const res = await axiosInstance.post("/auth/login", loginData); 29 | set({ authUser: res.data.user }); 30 | initializeSocket(res.data.user._id); 31 | toast.success("Logged in successfully"); 32 | } catch (error) { 33 | toast.error(error.response.data.message || "Something went wrong"); 34 | } finally { 35 | set({ loading: false }); 36 | } 37 | }, 38 | logout: async () => { 39 | try { 40 | const res = await axiosInstance.post("/auth/logout"); 41 | disconnectSocket(); 42 | if (res.status === 200) set({ authUser: null }); 43 | } catch (error) { 44 | toast.error(error.response.data.message || "Something went wrong"); 45 | } 46 | }, 47 | checkAuth: async () => { 48 | try { 49 | const res = await axiosInstance.get("/auth/me"); 50 | initializeSocket(res.data.user._id); 51 | set({ authUser: res.data.user }); 52 | } catch (error) { 53 | set({ authUser: null }); 54 | console.log(error); 55 | } finally { 56 | set({ checkingAuth: false }); 57 | } 58 | }, 59 | 60 | setAuthUser: (user) => set({ authUser: user }), 61 | })); 62 | -------------------------------------------------------------------------------- /client/src/components/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useAuthStore } from "../store/useAuthStore"; 3 | 4 | const LoginForm = () => { 5 | const [email, setEmail] = useState(""); 6 | const [password, setPassword] = useState(""); 7 | 8 | const { login, loading } = useAuthStore(); 9 | 10 | return ( 11 |
{ 14 | e.preventDefault(); 15 | login({ email, password }); 16 | }} 17 | > 18 |
19 | 22 |
23 | setEmail(e.target.value)} 31 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 32 | /> 33 |
34 |
35 | 36 |
37 | 40 |
41 | setPassword(e.target.value)} 49 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 50 | /> 51 |
52 |
53 | 54 | 66 |
67 | ); 68 | }; 69 | export default LoginForm; 70 | -------------------------------------------------------------------------------- /client/src/components/MessageInput.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useMessageStore } from "../store/useMessageStore"; 3 | import { Send, Smile } from "lucide-react"; 4 | import EmojiPicker from "emoji-picker-react"; 5 | 6 | const MessageInput = ({ match }) => { 7 | const [message, setMessage] = useState(""); 8 | const [showEmojiPicker, setShowEmojiPicker] = useState(false); 9 | const emojiPickerRef = useRef(null); 10 | 11 | const { sendMessage } = useMessageStore(); 12 | 13 | const handleSendMessage = (e) => { 14 | e.preventDefault(); 15 | if (message.trim()) { 16 | sendMessage(match._id, message); 17 | setMessage(""); 18 | } 19 | }; 20 | 21 | useEffect(() => { 22 | const handleClickOutside = (event) => { 23 | if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target)) { 24 | setShowEmojiPicker(false); 25 | } 26 | }; 27 | 28 | document.addEventListener("mousedown", handleClickOutside); 29 | return () => { 30 | document.removeEventListener("mousedown", handleClickOutside); 31 | }; 32 | }, []); 33 | 34 | return ( 35 |
36 | 43 | 44 | setMessage(e.target.value)} 48 | className='flex-grow p-3 pl-12 rounded-l-lg border-2 border-pink-500 49 | focus:outline-none focus:ring-2 focus:ring-pink-300' 50 | placeholder='Type a message...' 51 | /> 52 | 53 | 60 | {showEmojiPicker && ( 61 |
62 | { 64 | setMessage((prevMessage) => prevMessage + emojiObject.emoji); 65 | }} 66 | /> 67 |
68 | )} 69 |
70 | ); 71 | }; 72 | export default MessageInput; 73 | -------------------------------------------------------------------------------- /api/seeds/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import bcrypt from "bcryptjs"; 3 | import User from "../models/User.js"; 4 | import dotenv from "dotenv"; 5 | 6 | dotenv.config(); 7 | 8 | const maleNames = ["James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Thomas"]; 9 | 10 | const femaleNames = [ 11 | "Mary", 12 | "Patricia", 13 | "Jennifer", 14 | "Linda", 15 | "Elizabeth", 16 | "Barbara", 17 | "Susan", 18 | "Jessica", 19 | "Sarah", 20 | "Karen", 21 | "Nancy", 22 | "Lisa", 23 | ]; 24 | 25 | const genderPreferences = ["male", "female", "both"]; 26 | 27 | const bioDescriptors = [ 28 | "Coffee addict", 29 | "Cat lover", 30 | "Dog person", 31 | "Foodie", 32 | "Gym rat", 33 | "Bookworm", 34 | "Movie buff", 35 | "Music lover", 36 | "Travel junkie", 37 | "Beach bum", 38 | "City slicker", 39 | "Outdoor enthusiast", 40 | "Netflix binger", 41 | "Yoga enthusiast", 42 | "Craft beer connoisseur", 43 | "Sushi fanatic", 44 | "Adventure seeker", 45 | "Night owl", 46 | "Early bird", 47 | "Aspiring chef", 48 | ]; 49 | 50 | const generateBio = () => { 51 | const descriptors = bioDescriptors.sort(() => 0.5 - Math.random()).slice(0, 3); 52 | return descriptors.join(" | "); 53 | }; 54 | 55 | const generateRandomUser = (gender, index) => { 56 | const names = gender === "male" ? maleNames : femaleNames; 57 | const name = names[index]; 58 | const age = Math.floor(Math.random() * (45 - 21 + 1) + 21); 59 | return { 60 | name, 61 | email: `${name.toLowerCase()}${age}@example.com`, 62 | password: bcrypt.hashSync("password123", 10), 63 | age, 64 | gender, 65 | genderPreference: genderPreferences[Math.floor(Math.random() * genderPreferences.length)], 66 | bio: generateBio(), 67 | image: `/${gender}/${index + 1}.jpg`, 68 | }; 69 | }; 70 | 71 | const seedUsers = async () => { 72 | try { 73 | await mongoose.connect(process.env.MONGO_URI); 74 | 75 | await User.deleteMany({}); 76 | 77 | const maleUsers = maleNames.map((_, i) => generateRandomUser("male", i)); 78 | const femaleUsers = femaleNames.map((_, i) => generateRandomUser("female", i)); 79 | 80 | const allUsers = [...maleUsers, ...femaleUsers]; 81 | 82 | await User.insertMany(allUsers); 83 | 84 | console.log("Database seeded successfully with users having concise bios"); 85 | } catch (error) { 86 | console.error("Error seeding database:", error); 87 | } finally { 88 | mongoose.disconnect(); 89 | } 90 | }; 91 | 92 | seedUsers(); 93 | -------------------------------------------------------------------------------- /client/src/store/useMatchStore.js: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { axiosInstance } from "../lib/axios"; 3 | import toast from "react-hot-toast"; 4 | import { getSocket } from "../socket/socket.client"; 5 | 6 | export const useMatchStore = create((set) => ({ 7 | matches: [], 8 | isLoadingMyMatches: false, 9 | isLoadingUserProfiles: false, 10 | userProfiles: [], 11 | swipeFeedback: null, 12 | 13 | getMyMatches: async () => { 14 | try { 15 | set({ isLoadingMyMatches: true }); 16 | const res = await axiosInstance.get("/matches"); 17 | set({ matches: res.data.matches }); 18 | } catch (error) { 19 | set({ matches: [] }); 20 | toast.error(error.response.data.message || "Something went wrong"); 21 | } finally { 22 | set({ isLoadingMyMatches: false }); 23 | } 24 | }, 25 | 26 | getUserProfiles: async () => { 27 | try { 28 | set({ isLoadingUserProfiles: true }); 29 | const res = await axiosInstance.get("/matches/user-profiles"); 30 | set({ userProfiles: res.data.users }); 31 | } catch (error) { 32 | set({ userProfiles: [] }); 33 | toast.error(error.response.data.message || "Something went wrong"); 34 | } finally { 35 | set({ isLoadingUserProfiles: false }); 36 | } 37 | }, 38 | 39 | swipeLeft: async (user) => { 40 | try { 41 | set({ swipeFeedback: "passed" }); 42 | await axiosInstance.post("/matches/swipe-left/" + user._id); 43 | } catch (error) { 44 | console.log(error); 45 | toast.error("Failed to swipe left"); 46 | } finally { 47 | setTimeout(() => set({ swipeFeedback: null }), 1500); 48 | } 49 | }, 50 | swipeRight: async (user) => { 51 | try { 52 | set({ swipeFeedback: "liked" }); 53 | await axiosInstance.post("/matches/swipe-right/" + user._id); 54 | } catch (error) { 55 | console.log(error); 56 | toast.error("Failed to swipe right"); 57 | } finally { 58 | setTimeout(() => set({ swipeFeedback: null }), 1500); 59 | } 60 | }, 61 | 62 | subscribeToNewMatches: () => { 63 | try { 64 | const socket = getSocket(); 65 | 66 | socket.on("newMatch", (newMatch) => { 67 | set((state) => ({ 68 | matches: [...state.matches, newMatch], 69 | })); 70 | toast.success("You got a new match!"); 71 | }); 72 | } catch (error) { 73 | console.log(error); 74 | } 75 | }, 76 | 77 | unsubscribeFromNewMatches: () => { 78 | try { 79 | const socket = getSocket(); 80 | socket.off("newMatch"); 81 | } catch (error) { 82 | console.error(error); 83 | } 84 | }, 85 | })); 86 | -------------------------------------------------------------------------------- /client/src/pages/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import Sidebar from "../components/Sidebar"; 3 | import { Header } from "../components/Header"; 4 | import { useMatchStore } from "../store/useMatchStore"; 5 | import { Frown } from "lucide-react"; 6 | 7 | import SwipeArea from "../components/SwipeArea"; 8 | import SwipeFeedback from "../components/SwipeFeedback"; 9 | import { useAuthStore } from "../store/useAuthStore"; 10 | 11 | const HomePage = () => { 12 | const { isLoadingUserProfiles, getUserProfiles, userProfiles, subscribeToNewMatches, unsubscribeFromNewMatches } = 13 | useMatchStore(); 14 | 15 | const { authUser } = useAuthStore(); 16 | 17 | useEffect(() => { 18 | getUserProfiles(); 19 | }, [getUserProfiles]); 20 | 21 | useEffect(() => { 22 | authUser && subscribeToNewMatches(); 23 | 24 | return () => { 25 | unsubscribeFromNewMatches(); 26 | }; 27 | }, [subscribeToNewMatches, unsubscribeFromNewMatches, authUser]); 28 | 29 | return ( 30 |
35 | 36 |
37 |
38 |
39 | {userProfiles.length > 0 && !isLoadingUserProfiles && ( 40 | <> 41 | 42 | 43 | 44 | )} 45 | 46 | {userProfiles.length === 0 && !isLoadingUserProfiles && } 47 | 48 | {isLoadingUserProfiles && } 49 |
50 |
51 |
52 | ); 53 | }; 54 | export default HomePage; 55 | 56 | const NoMoreProfiles = () => ( 57 |
58 | 59 |

Woah there, speedy fingers!

60 |

Bro are you OK? Maybe it's time to touch some grass.

61 |
62 | ); 63 | 64 | const LoadingUI = () => { 65 | return ( 66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /api/controllers/authController.js: -------------------------------------------------------------------------------- 1 | import User from "../models/User.js"; 2 | import jwt from "jsonwebtoken"; 3 | 4 | const signToken = (id) => { 5 | // jwt token 6 | return jwt.sign({ id }, process.env.JWT_SECRET, { 7 | expiresIn: "7d", 8 | }); 9 | }; 10 | 11 | export const signup = async (req, res) => { 12 | const { name, email, password, age, gender, genderPreference } = req.body; 13 | try { 14 | if (!name || !email || !password || !age || !gender || !genderPreference) { 15 | return res.status(400).json({ 16 | success: false, 17 | message: "All fields are required", 18 | }); 19 | } 20 | 21 | if (age < 18) { 22 | return res.status(400).json({ 23 | success: false, 24 | message: "You must at lest 18 years old", 25 | }); 26 | } 27 | 28 | if (password.length < 6) { 29 | return res.status(400).json({ 30 | success: false, 31 | message: "Password must be at least 6 characters", 32 | }); 33 | } 34 | 35 | const newUser = await User.create({ 36 | name, 37 | email, 38 | password, 39 | age, 40 | gender, 41 | genderPreference, 42 | }); 43 | 44 | const token = signToken(newUser._id); 45 | 46 | res.cookie("jwt", token, { 47 | maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds 48 | httpOnly: true, // prevents XSS attacks 49 | sameSite: "strict", // prevents CSRF attacks 50 | secure: process.env.NODE_ENV === "production", 51 | }); 52 | 53 | res.status(201).json({ 54 | success: true, 55 | user: newUser, 56 | }); 57 | } catch (error) { 58 | console.log("Error in signup controller:", error); 59 | res.status(500).json({ success: false, message: "Server error" }); 60 | } 61 | }; 62 | export const login = async (req, res) => { 63 | const { email, password } = req.body; 64 | try { 65 | if (!email || !password) { 66 | return res.status(400).json({ 67 | success: false, 68 | message: "All fields are required", 69 | }); 70 | } 71 | 72 | const user = await User.findOne({ email }).select("+password"); 73 | 74 | if (!user || !(await user.matchPassword(password))) { 75 | return res.status(401).json({ 76 | success: false, 77 | message: "Invalid email or password", 78 | }); 79 | } 80 | 81 | const token = signToken(user._id); 82 | 83 | res.cookie("jwt", token, { 84 | maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds 85 | httpOnly: true, // prevents XSS attacks 86 | sameSite: "strict", // prevents CSRF attacks 87 | secure: process.env.NODE_ENV === "production", 88 | }); 89 | 90 | res.status(200).json({ 91 | success: true, 92 | user, 93 | }); 94 | } catch (error) { 95 | console.log("Error in login controller:", error); 96 | res.status(500).json({ success: false, message: "Server error" }); 97 | } 98 | }; 99 | export const logout = async (req, res) => { 100 | res.clearCookie("jwt"); 101 | res.status(200).json({ success: true, message: "Logged out successfully" }); 102 | }; 103 | -------------------------------------------------------------------------------- /client/src/components/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Heart, Loader, MessageCircle, X } from "lucide-react"; 3 | import { Link } from "react-router-dom"; 4 | import { useMatchStore } from "../store/useMatchStore"; 5 | 6 | const Sidebar = () => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | 9 | const toggleSidebar = () => setIsOpen(!isOpen); 10 | 11 | const { getMyMatches, matches, isLoadingMyMatches } = useMatchStore(); 12 | 13 | useEffect(() => { 14 | getMyMatches(); 15 | }, [getMyMatches]); 16 | 17 | return ( 18 | <> 19 |
27 |
28 | {/* Header */} 29 |
30 |

Matches

31 | 37 |
38 | 39 |
40 | {isLoadingMyMatches ? ( 41 | 42 | ) : matches.length === 0 ? ( 43 | 44 | ) : ( 45 | matches.map((match) => ( 46 | 47 |
48 | User avatar 53 | 54 |

{match.name}

55 |
56 | 57 | )) 58 | )} 59 |
60 |
61 |
62 | 63 | 69 | 70 | ); 71 | }; 72 | export default Sidebar; 73 | 74 | const NoMatchesFound = () => ( 75 |
76 | 77 |

No Matches Yet

78 |

79 | Don't worry! Your perfect match is just around the corner. Keep swiping! 80 |

81 |
82 | ); 83 | 84 | const LoadingState = () => ( 85 |
86 | 87 |

Loading Matches

88 |

We're finding your perfect matches. This might take a moment...

89 |
90 | ); 91 | -------------------------------------------------------------------------------- /api/controllers/matchController.js: -------------------------------------------------------------------------------- 1 | import User from "../models/User.js"; 2 | import { getConnectedUsers, getIO } from "../socket/socket.server.js"; 3 | 4 | export const swipeRight = async (req, res) => { 5 | try { 6 | const { likedUserId } = req.params; 7 | const currentUser = await User.findById(req.user.id); 8 | const likedUser = await User.findById(likedUserId); 9 | 10 | if (!likedUser) { 11 | return res.status(404).json({ 12 | success: false, 13 | message: "User not found", 14 | }); 15 | } 16 | 17 | if (!currentUser.likes.includes(likedUserId)) { 18 | currentUser.likes.push(likedUserId); 19 | await currentUser.save(); 20 | 21 | // if the other user already liked us, it's a match, so let's update both users 22 | if (likedUser.likes.includes(currentUser.id)) { 23 | currentUser.matches.push(likedUserId); 24 | likedUser.matches.push(currentUser.id); 25 | 26 | await Promise.all([await currentUser.save(), await likedUser.save()]); 27 | 28 | // send notification in real-time with socket.io 29 | const connectedUsers = getConnectedUsers(); 30 | const io = getIO(); 31 | 32 | const likedUserSocketId = connectedUsers.get(likedUserId); 33 | 34 | if (likedUserSocketId) { 35 | io.to(likedUserSocketId).emit("newMatch", { 36 | _id: currentUser._id, 37 | name: currentUser.name, 38 | image: currentUser.image, 39 | }); 40 | } 41 | 42 | const currentSocketId = connectedUsers.get(currentUser._id.toString()); 43 | if (currentSocketId) { 44 | io.to(currentSocketId).emit("newMatch", { 45 | _id: likedUser._id, 46 | name: likedUser.name, 47 | image: likedUser.image, 48 | }); 49 | } 50 | } 51 | } 52 | 53 | res.status(200).json({ 54 | success: true, 55 | user: currentUser, 56 | }); 57 | } catch (error) { 58 | console.log("Error in swipeRight: ", error); 59 | 60 | res.status(500).json({ 61 | success: false, 62 | message: "Internal server error", 63 | }); 64 | } 65 | }; 66 | 67 | export const swipeLeft = async (req, res) => { 68 | try { 69 | const { dislikedUserId } = req.params; 70 | const currentUser = await User.findById(req.user.id); 71 | 72 | if (!currentUser.dislikes.includes(dislikedUserId)) { 73 | currentUser.dislikes.push(dislikedUserId); 74 | await currentUser.save(); 75 | } 76 | 77 | res.status(200).json({ 78 | success: true, 79 | user: currentUser, 80 | }); 81 | } catch (error) { 82 | console.log("Error in swipeLeft: ", error); 83 | 84 | res.status(500).json({ 85 | success: false, 86 | message: "Internal server error", 87 | }); 88 | } 89 | }; 90 | 91 | export const getMatches = async (req, res) => { 92 | try { 93 | const user = await User.findById(req.user.id).populate("matches", "name image"); 94 | 95 | res.status(200).json({ 96 | success: true, 97 | matches: user.matches, 98 | }); 99 | } catch (error) { 100 | console.log("Error in getMatches: ", error); 101 | 102 | res.status(500).json({ 103 | success: false, 104 | message: "Internal server error", 105 | }); 106 | } 107 | }; 108 | 109 | export const getUserProfiles = async (req, res) => { 110 | try { 111 | const currentUser = await User.findById(req.user.id); 112 | 113 | const users = await User.find({ 114 | $and: [ 115 | { _id: { $ne: currentUser.id } }, 116 | { _id: { $nin: currentUser.likes } }, 117 | { _id: { $nin: currentUser.dislikes } }, 118 | { _id: { $nin: currentUser.matches } }, 119 | { 120 | gender: 121 | currentUser.genderPreference === "both" 122 | ? { $in: ["male", "female"] } 123 | : currentUser.genderPreference, 124 | }, 125 | { genderPreference: { $in: [currentUser.gender, "both"] } }, 126 | ], 127 | }); 128 | 129 | res.status(200).json({ 130 | success: true, 131 | users, 132 | }); 133 | } catch (error) { 134 | console.log("Error in getUserProfiles: ", error); 135 | 136 | res.status(500).json({ 137 | success: false, 138 | message: "Internal server error", 139 | }); 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /client/src/pages/ChatPage.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Header } from "../components/Header"; 3 | import { useAuthStore } from "../store/useAuthStore"; 4 | import { useMatchStore } from "../store/useMatchStore"; 5 | import { useMessageStore } from "../store/useMessageStore"; 6 | import { Link, useParams } from "react-router-dom"; 7 | import { Loader, UserX } from "lucide-react"; 8 | import MessageInput from "../components/MessageInput"; 9 | 10 | const ChatPage = () => { 11 | const { getMyMatches, matches, isLoadingMyMatches } = useMatchStore(); 12 | const { messages, getMessages, subscribeToMessages, unsubscribeFromMessages } = useMessageStore(); 13 | const { authUser } = useAuthStore(); 14 | 15 | const { id } = useParams(); 16 | 17 | const match = matches.find((m) => m?._id === id); 18 | 19 | useEffect(() => { 20 | if (authUser && id) { 21 | getMyMatches(); 22 | getMessages(id); 23 | subscribeToMessages(); 24 | } 25 | 26 | return () => { 27 | unsubscribeFromMessages(); 28 | }; 29 | }, [getMyMatches, authUser, getMessages, subscribeToMessages, unsubscribeFromMessages, id]); 30 | 31 | if (isLoadingMyMatches) return ; 32 | if (!match) return ; 33 | 34 | return ( 35 |
36 |
37 | 38 |
39 |
40 | 44 |

{match.name}

45 |
46 | 47 |
48 | {messages.length === 0 ? ( 49 |

Start your conversation with {match.name}

50 | ) : ( 51 | messages.map((msg) => ( 52 |
56 | 63 | {msg.content} 64 | 65 |
66 | )) 67 | )} 68 |
69 | 70 |
71 |
72 | ); 73 | }; 74 | export default ChatPage; 75 | 76 | const MatchNotFound = () => ( 77 |
78 |
79 | 80 |

Match Not Found

81 |

Oops! It seems this match doesn't exist or has been removed.

82 | 87 | Go Back To Home 88 | 89 |
90 |
91 | ); 92 | 93 | const LoadingMessagesUI = () => ( 94 |
95 |
96 | 97 |

Loading Chat

98 |

Please wait while we fetch your conversation...

99 |
100 |
101 |
105 |
109 |
110 |
111 |
112 | ); 113 | -------------------------------------------------------------------------------- /client/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useAuthStore } from "../store/useAuthStore"; 3 | import { Link } from "react-router-dom"; 4 | import { Flame, User, LogOut, Menu } from "lucide-react"; 5 | 6 | export const Header = () => { 7 | const { authUser, logout } = useAuthStore(); 8 | const [dropdownOpen, setDropdownOpen] = useState(false); 9 | const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 10 | const dropdownRef = useRef(null); 11 | 12 | useEffect(() => { 13 | const handleClickOutside = (event) => { 14 | if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { 15 | setDropdownOpen(false); 16 | } 17 | }; 18 | 19 | document.addEventListener("mousedown", handleClickOutside); 20 | 21 | return () => document.removeEventListener("mousedown", handleClickOutside); 22 | }, []); 23 | 24 | return ( 25 |
26 |
27 |
28 |
29 | 30 | 31 | Swipe 32 | 33 |
34 | 35 |
36 | {authUser ? ( 37 |
38 | 49 | {dropdownOpen && ( 50 |
51 | setDropdownOpen(false)} 55 | > 56 | 57 | Profile 58 | 59 | 66 |
67 | )} 68 |
69 | ) : ( 70 | <> 71 | 75 | Login 76 | 77 | 82 | Sign Up 83 | 84 | 85 | )} 86 |
87 | 88 |
89 | 95 |
96 |
97 |
98 | 99 | {/* MOBILE MENU */} 100 | 101 | {mobileMenuOpen && ( 102 |
103 |
104 | {authUser ? ( 105 | <> 106 | setMobileMenuOpen(false)} 110 | > 111 | Profile 112 | 113 | 122 | 123 | ) : ( 124 | <> 125 | setMobileMenuOpen(false)} 129 | > 130 | Login 131 | 132 | setMobileMenuOpen(false)} 136 | > 137 | Sign Up 138 | 139 | 140 | )} 141 |
142 |
143 | )} 144 |
145 | ); 146 | }; 147 | -------------------------------------------------------------------------------- /client/src/components/SignUpForm.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useAuthStore } from "../store/useAuthStore"; 3 | 4 | const SignUpForm = () => { 5 | const [name, setName] = useState(""); 6 | const [email, setEmail] = useState(""); 7 | const [password, setPassword] = useState(""); 8 | const [gender, setGender] = useState(""); 9 | const [age, setAge] = useState(""); 10 | const [genderPreference, setGenderPreference] = useState(""); 11 | 12 | const { signup, loading } = useAuthStore(); 13 | 14 | return ( 15 |
{ 18 | e.preventDefault(); 19 | signup({ name, email, password, gender, age, genderPreference }); 20 | }} 21 | > 22 | {/* NAME */} 23 |
24 | 27 |
28 | setName(e.target.value)} 35 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 36 | /> 37 |
38 |
39 | 40 | {/* EMAIL */} 41 |
42 | 45 |
46 | setEmail(e.target.value)} 54 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 55 | /> 56 |
57 |
58 | 59 | {/* PASSWORD */} 60 |
61 | 64 |
65 | setPassword(e.target.value)} 73 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 74 | /> 75 |
76 |
77 | 78 | {/* AGE */} 79 |
80 | 83 |
84 | setAge(e.target.value)} 91 | min='18' 92 | max='120' 93 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 94 | /> 95 |
96 |
97 | 98 | {/* GENDER */} 99 |
100 | 101 |
102 |
103 | setGender("male")} 109 | className='h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded' 110 | /> 111 | 114 |
115 |
116 | setGender("female")} 122 | className='h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded' 123 | /> 124 | 127 |
128 |
129 |
130 | 131 | {/* GENDER PREFERENCE */} 132 |
133 | 134 |
135 |
136 | setGenderPreference(e.target.value)} 143 | className='h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300' 144 | /> 145 | 148 |
149 |
150 | setGenderPreference(e.target.value)} 157 | className='h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300' 158 | /> 159 | 162 |
163 |
164 | setGenderPreference(e.target.value)} 171 | className='h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300' 172 | /> 173 | 176 |
177 |
178 |
179 | 180 |
181 | 192 |
193 |
194 | ); 195 | }; 196 | export default SignUpForm; 197 | -------------------------------------------------------------------------------- /client/src/pages/ProfilePage.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import { Header } from "../components/Header"; 3 | import { useAuthStore } from "../store/useAuthStore"; 4 | import { useUserStore } from "../store/useUserStore"; 5 | 6 | const ProfilePage = () => { 7 | const { authUser } = useAuthStore(); 8 | const [name, setName] = useState(authUser.name || ""); 9 | const [bio, setBio] = useState(authUser.bio || ""); 10 | const [age, setAge] = useState(authUser.age || ""); 11 | const [gender, setGender] = useState(authUser.gender || ""); 12 | const [genderPreference, setGenderPreference] = useState(authUser.genderPreference || []); 13 | const [image, setImage] = useState(authUser.image || null); 14 | 15 | const fileInputRef = useRef(null); 16 | 17 | const { loading, updateProfile } = useUserStore(); 18 | 19 | const handleSubmit = (e) => { 20 | e.preventDefault(); 21 | updateProfile({ name, bio, age, gender, genderPreference, image }); 22 | }; 23 | 24 | const handleImageChange = (e) => { 25 | const file = e.target.files[0]; 26 | if (file) { 27 | const reader = new FileReader(); 28 | reader.onloadend = () => { 29 | setImage(reader.result); 30 | }; 31 | 32 | reader.readAsDataURL(file); 33 | } 34 | }; 35 | 36 | console.log(image); 37 | 38 | return ( 39 |
40 |
41 | 42 |
43 |
44 |

Your Profile

45 |
46 | 47 |
48 |
49 |
50 | {/* NAME */} 51 |
52 | 55 |
56 | setName(e.target.value)} 63 | className='appearance-none block w-full px-3 py-2 border border-gray-300 64 | rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 65 | sm:text-sm' 66 | /> 67 |
68 |
69 | 70 | {/* AGE */} 71 |
72 | 75 |
76 | setAge(e.target.value)} 83 | className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-pink-500 focus:border-pink-500 sm:text-sm' 84 | /> 85 |
86 |
87 | 88 | {/* GENDER */} 89 |
90 | Gender 91 |
92 | {["Male", "Female"].map((option) => ( 93 | 104 | ))} 105 |
106 |
107 | 108 | {/* GENDER PREFERENCE */} 109 |
110 | Gender Preference 111 |
112 | {["Male", "Female", "Both"].map((option) => ( 113 | 122 | ))} 123 |
124 |
125 | 126 | {/* BIO */} 127 | 128 |
129 | 132 |
133 |