diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..90d7371 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,35 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Node.js CI + +env: + MONGODB_URI: ${{ vars.MONGODB_URI }} +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + environment: build + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - run: npm i + - run: npm run build diff --git a/app/about.jsx b/app/about.jsx new file mode 100644 index 0000000..c683b3c --- /dev/null +++ b/app/about.jsx @@ -0,0 +1,60 @@ +import Image from 'next/image'; + +const AboutUs = () =>{ + return ( +
+
+
+

Meet Our Team

+

+ We’re the passionate developers behind Recipe Rush, dedicated to making culinary exploration easy, fun, and accessible for everyone. Our team brings together diverse skills and a shared love for cooking to build a platform that inspires people to try new recipes, master classics, and find joy in the kitchen. +

+
+
+ {[ + { name: "Nomusa Mtshali", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/nomusamtshali", linkin: "https://www.linkedin.com/in/nomusa-mtshali/" }, + { name: "Phillip Bogopane", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/Phillip-tech", linkin: "https://linkedin.com/in/username" }, + { name: "Kutlwano Ramotebele", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/kutlwano10", linkin: "https://www.linkedin.com/in/kutlwano-ramotebele-769461296/" }, + { name: "Kealeboga A Ntheledi", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/Kea-Angel-Ntheledi", linkin: "https://linkedin.com/in/username" }, + { name: "Koketso Moilwe", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/KoketsoMoilwe20", linkin: "https://www.linkedin.com/in/koketsomoilwe/" }, + { name: "Karabo M. Radebe", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/Karabo-M-Radebe", linkin: "https://www.linkedin.com/in/karabo-m-radebe/" }, + { name: "Kitso Mogale", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/KitsoMogale", linkin: "https://www.linkedin.com/in/kitso-mogale-200663321/" }, + { name: "Mmakgosana Makgaka", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/Mmakgosana", linkin: "https://www.linkedin.com/in/mmakgosana-makgaka-b32478313/" }, + { name: "Mateo Benzien", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/Mateo-Benzien", linkin: "https://www.linkedin.com/in/mateo-benzien-857864302/" }, + { name: "Sabelo Mawela", role: "add role", imgSrc: "https://via.placeholder.com/150", github: "https://github.com/SABELOMAWELA", linkin: "https://www.linkedin.com/in/sabelo-mawela-480793296/" }, + ].map((member, index) => ( +
+
+ {`${member.name} +
+

{member.name}

+ {member.role} +
+ + {/* GitHub Icon */} + + + + + + {/* LinkedIn Icon */} + + + + +
+
+ ))} +
+
+
+ ); +}; + +export default AboutUs; \ No newline at end of file diff --git a/app/all/page.jsx b/app/all/page.jsx new file mode 100644 index 0000000..4e94a92 --- /dev/null +++ b/app/all/page.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import RecipeGrid from '../components/RecipeGrid' + +const AllRecipes = ({searchParams}) => { + return ( +
+
+ +
+
+ ) +} + +export default AllRecipes diff --git a/app/api/10Recipes/route.js b/app/api/10Recipes/route.js new file mode 100644 index 0000000..ab1dea6 --- /dev/null +++ b/app/api/10Recipes/route.js @@ -0,0 +1,39 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import Recipe from "@/app/models/Recipe"; +import { NextResponse } from "next/server"; + +export async function GET(req) { + try { + await connectToDatabase(); + + const recipes = await Recipe.aggregate([ + { + $lookup: { + from: "reviews", + localField: "_id", + foreignField: "recipeId", + as: "reviews", + }, + }, + { + $addFields: { + averageRating: { $avg: "$reviews.rating" }, + }, + }, + { + $sort: { averageRating: -1 }, + }, + { + $limit: 10, + }, + ]); + + return NextResponse.json({ success: true, recipes }); + } catch (error) { + console.error("Error fetching recipes:", error); + return NextResponse.json( + { success: false, message: "Failed to fetch recipes." }, + { status: 500 } + ); + } +} diff --git a/app/api/addReview/route.js b/app/api/addReview/route.js new file mode 100644 index 0000000..fdbc6f5 --- /dev/null +++ b/app/api/addReview/route.js @@ -0,0 +1,34 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import Review from "@/app/models/reviews"; +import { NextResponse } from "next/server"; + +export async function POST(req) { + await connectToDatabase(); + + try { + const { recipeId, comment, rating, reviewerName } = await req.json(); + + // Validate request data + if ( !comment || !rating ) { + return NextResponse.json( + { error: "All fields are required" }, + { status: 400 } + ); + } + + // Create a new review document + const newReview = new Review({ recipeId, comment, rating, reviewerName }); + const savedReview = await newReview.save(); + + // Return success response + return NextResponse.json(savedReview, { status: 201 }); + } catch (error) { + console.error("Error saving review:", error); + + // Return error response + return NextResponse.json( + { error: "Failed to add review" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/[...nextauth]/route.js b/app/api/auth/[...nextauth]/route.js new file mode 100644 index 0000000..675c735 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.js @@ -0,0 +1,58 @@ +import NextAuth from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { MongoDBAdapter } from "@auth/mongodb-adapter"; +import { clientPromise } from "@/app/lib/connectMongoose"; +import User from "@/app/models/user"; +import bcrypt from "bcrypt"; + +const authOptions = { + adapter: MongoDBAdapter(clientPromise, { + databaseName: "devdb", // Explicitly specify your database + collections: { + users: "users", + accounts: "accounts", + sessions: "sessions", + }, + }), + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }), + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const { email, password } = credentials; + const user = await User.findOne({ email }); + if (!user) throw new Error("No user found with this email"); + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) throw new Error("Invalid password"); + return { id: user._id.toString(), email: user.email, name: user.name }; + }, + }), + ], + secret: process.env.NEXTAUTH_SECRET, + session: { strategy: "jwt" }, + callbacks: { + async session({ session, token }) { + session.user.id = token.sub; + session.user.email = token.email; + session.user.provider = token.provider; + return session; + }, + async jwt({ token, account }) { + if (account) { + token.provider = account.provider; + } + return token; + }, + }, +}; + +export const GET = NextAuth(authOptions); +export const POST = NextAuth(authOptions); diff --git a/app/api/auth/register/route.js b/app/api/auth/register/route.js new file mode 100644 index 0000000..b6f7be3 --- /dev/null +++ b/app/api/auth/register/route.js @@ -0,0 +1,39 @@ +// pages/api/auth/register.js +import bcrypt from "bcrypt"; +import User from "@/app/models/user"; // Assuming your User model is here +import connectToDatabase from "@/app/lib/connectMongoose"; +import { NextResponse } from "next/server"; + +export async function POST(req) { + await connectToDatabase(); + + // Log to confirm request received + console.log("signup123"); + + // Parse JSON from the request body + const { email, password, name } = await req.json(); + console.log(email, password, name); + + // Check if the email is already in use + const existingUser = await User.findOne({ email }); + if (existingUser) { + // console.log(existingUser) + return NextResponse.json({ error: "Email already in use" }, { status: 400 }); + } + // Hash the password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + //console.log(User.schema.paths); + + // Create and save the new user + const newUser = new User({ + email, + password: hashedPassword, + name, + }); + + await newUser.validate(); // Check validation errors + await newUser.save(); + // Return success response + return NextResponse.json({ message: "User registered successfully" }, { status: 201 }); +} diff --git a/app/api/categories/route.js b/app/api/categories/route.js new file mode 100644 index 0000000..e8693e5 --- /dev/null +++ b/app/api/categories/route.js @@ -0,0 +1,36 @@ +import connectToDatabase from '@/app/lib/connectMongoose'; +// pages/api/categories.js +import { NextResponse } from 'next/server'; +import Categories from "@/app/models/categories"; + +export async function GET() { + // Connect to MongoDB + await connectToDatabase(); + try { + // Fetch the categories document + const categoryDoc = await Categories.findOne({}); + if (!categoryDoc) { + return NextResponse.json({ message: "Categories not found" }, { status: 404 },{ + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + // Send back the categories array + return NextResponse.json({ categories: categoryDoc.categories }, { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } catch (error) { + console.error("Error fetching categories:", error); + return NextResponse.json({ message: "Internal server error" }, { status: 500 },{ + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); + } + } \ No newline at end of file diff --git a/app/api/deleteReview/route.js b/app/api/deleteReview/route.js new file mode 100644 index 0000000..25d37b3 --- /dev/null +++ b/app/api/deleteReview/route.js @@ -0,0 +1,27 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import Review from "@/app/models/reviews"; +import { NextResponse } from "next/server"; + +export async function DELETE(req) { + await connectToDatabase(); + + try { + const { id } = await req.json(); + + // Validate + if (!id) { + return NextResponse.json( + { error: "Review ID is required" }, + { status: 400 } + ); + } + + // Delete the review + await Review.findByIdAndDelete(id); + + return NextResponse.json({ message: "Review deleted successfully" }, { status: 200 }); + } catch (error) { + console.error("Error deleting review:", error); + return NextResponse.json({ error: "Failed to delete review" }, { status: 500 }); + } +} diff --git a/app/api/editReview/route.js b/app/api/editReview/route.js new file mode 100644 index 0000000..99924fc --- /dev/null +++ b/app/api/editReview/route.js @@ -0,0 +1,31 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import Review from "@/app/models/reviews"; +import { NextResponse } from "next/server"; + +export async function PATCH(req) { + await connectToDatabase(); + + try { + const { id, comment, rating } = await req.json(); + + // Validate + if (!id || !comment || !rating) { + return NextResponse.json( + { error: "All fields are required" }, + { status: 400 } + ); + } + + // Update the review + const updatedReview = await Review.findByIdAndUpdate( + id, + { comment, rating }, + { new: true } + ); + + return NextResponse.json(updatedReview, { status: 200 }); + } catch (error) { + console.error("Error editing review:", error); + return NextResponse.json({ error: "Failed to edit review" }, { status: 500 }); + } +} diff --git a/app/api/getReviews/route.js b/app/api/getReviews/route.js new file mode 100644 index 0000000..2f02dd7 --- /dev/null +++ b/app/api/getReviews/route.js @@ -0,0 +1,35 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import Review from "@/app/models/reviews"; +import { NextResponse } from "next/server"; + +export async function GET(req) { + await connectToDatabase(); + + try { + // Extract query parameters + const { searchParams } = new URL(req.url); + const recipeId = searchParams.get("recipeId"); + + // Validate that recipeId is provided + if (!recipeId) { + return NextResponse.json( + { error: "recipeId query parameter is required" }, + { status: 400 } + ); + } + + // Fetch reviews for the given recipeId + const reviews = await Review.find({ recipeId }); + + // Return reviews in response + return NextResponse.json(reviews, { status: 200 }); + } catch (error) { + console.error("Error fetching reviews:", error); + + // Return error response + return NextResponse.json( + { error: "Failed to fetch reviews" }, + { status: 500 } + ); + } +} diff --git a/app/api/recipe/[id]/route.js b/app/api/recipe/[id]/route.js index 4f553c3..50c76e9 100644 --- a/app/api/recipe/[id]/route.js +++ b/app/api/recipe/[id]/route.js @@ -1,6 +1,7 @@ -import connectToDatabase from "@/lib/connectMongoose"; -import Recipe from "@/models/Recipe"; +import connectToDatabase from "@/app/lib/connectMongoose"; +import Recipe from "@/app/models/Recipe"; import { NextResponse } from "next/server"; +import mongoose from "mongoose"; /** * @@ -12,8 +13,11 @@ import { NextResponse } from "next/server"; export async function GET(req, { params }) { try { let { id } = params; - await connectToDatabase(); - const recipe = await Recipe.findOne({ _id: id }).lean(); + console.log(id); + // id = new mongoose.Types.ObjectId(); + console.log(id); + await connectToDatabase(); + const recipe = await Recipe.findOne({ _id: id }); return NextResponse.json({ recipe }, { status: 200 }); } catch (error) { diff --git a/app/api/recipe/[id]/update/route.js b/app/api/recipe/[id]/update/route.js new file mode 100644 index 0000000..8235bac --- /dev/null +++ b/app/api/recipe/[id]/update/route.js @@ -0,0 +1,25 @@ +// app/api/recipes/[id]/update.js + +import connectToDatabase from "@/app/lib/connectMongoose"; +import Recipe from "@/app/models/Recipe"; +import { NextResponse } from "next/server"; + +export async function PUT(req, { params }) { + const { id } = params; // Recipe ID from the URL + const { description } = await req.json(); // New description from the request body + + await connectToDatabase(); + console.log('update desc') + try { + const result = await Recipe.findByIdAndUpdate(id, { description }, { new: true }); + + if (result) { + return NextResponse.json({ message: "Recipe updated successfully", recipe: result }); + } else { + return NextResponse.json({ error: "Recipe not found" }, { status: 404 }); + } + } catch (error) { + console.error(error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/recipe/route.js b/app/api/recipe/route.js index 74e8e86..a4c070c 100644 --- a/app/api/recipe/route.js +++ b/app/api/recipe/route.js @@ -1,15 +1,119 @@ -import connectToDatabase from "../../../lib/connectMongoose"; -import Recipe from "../../../models/Recipe"; + +import connectToDatabase from "@/app/lib/connectMongoose"; +import Recipe from "@/app/models/Recipe"; import { NextResponse } from "next/server"; +/** + * + * @param {searchParams} req - This will get + * @returns + */ + +export const dynamic = 'force-dynamic'; // Add this line to handle dynamic rendering export async function GET(req) { - console.log('cjsd csdcdsc') try { await connectToDatabase(); - const recipes = await Recipe.find({}).lean().limit(50); - console.log(recipes) - return NextResponse.json({ recipes }); + const { searchParams } = new URL(req.url); + const search = searchParams.get("search"); + const skip = parseInt(searchParams.get("skip"), 10) || 0; + const limit = parseInt(searchParams.get("limit"), 10) || 50; + let query = {}; + const category = searchParams.get("category"); + const sort = searchParams.get("sortOption"); + const tags = searchParams.get("tags"); + const ingredients = searchParams.get("ingredients"); + const numSteps = parseInt(searchParams.get("numSteps"), 10); // Convert numSteps to integer + console.log(skip,"1234f"); + + // Build the query based on the search parameter + if (search) { + query.title = { $regex: search, $options: "i" }; + } + + // Filter by category if provided + if (category && category !== "All Categories") { + query.category = category; + } + + // Filter by tags if provided + if (tags && tags.length > 0) { + query.tags = { $all: tags.split(",") }; // Matches all selected tags + } + + // Filter by ingredients if provided + if (ingredients && ingredients.length > 0) { + const ingredientsArray = ingredients.split(","); // Assuming ingredients are comma-separated in the query + query["$and"] = ingredientsArray.map((ingredient) => ({ + [`ingredients.${ingredient}`]: { $exists: true }, + })); + } + + // Filter by number of steps if provided + if (numSteps) { + query.instructions = { $size: numSteps }; + } + + // Define the sorting options based on the sort parameter + let sortOptions = {}; + switch (sort) { + case "prep_asc": + sortOptions.prep = 1; + break; + case "prep_desc": + sortOptions.prep = -1; + break; + case "cook_asc": + sortOptions.cook = 1; + break; + case "cook_desc": + sortOptions.cook = -1; + break; + case "steps_asc": + sortOptions.instructions = 1; + break; + case "steps_desc": + sortOptions.instructions = -1; + break; + case "newest": + sortOptions.createdAt = -1; + break; + case "oldest": + sortOptions.createdAt = 1; + break; + default: + break; + } + console.log(Recipe.collection.name) + // Fetch recipes with the built query and sort options, limited to 50 results + const recipes = await Recipe.find(query) + .sort(sortOptions) + .limit(limit) + .skip(skip) + + + // Get the count of recipes matching the search or category filter + let count; + if ( + search || + (category && category !== "All Categories" && category !== "all") + ) { + count = recipes.length; + } + + return NextResponse.json({ success: true, recipes, count }, { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); } catch (error) { - console.error(error); + console.error("Error searching recipes:", error); + return NextResponse.json( + { success: false, message: "Failed to search recipes." }, + { status: 500 } + ); } } + + + diff --git a/app/api/user/route.js b/app/api/user/route.js new file mode 100644 index 0000000..3a6428b --- /dev/null +++ b/app/api/user/route.js @@ -0,0 +1,91 @@ +import connectToDatabase from "@/app/lib/connectMongoose"; +import { NextResponse } from "next/server"; +import mongoose from "mongoose"; +import User from "@/app/models/user"; + +// Define user schemas for both databases +const testUserSchema = new mongoose.Schema({ + name: String, + email: String, + image: String, + emailVerified: String, +}); + +const devUserSchema = new mongoose.Schema({ + email: String, + name: String, + password: String, +}); + +// Fetch user profile +export async function GET(req) { + const { searchParams } = new URL(req.url); + const userEmail = searchParams.get("user"); + const db = searchParams.get("db"); // specify 'test' or 'devdb' here + + // Determine which database to connect to + const databaseName = db === "test" ? "test" : "devdb"; + const connection = await connectToDatabase(); + + // Define or retrieve the User model for the specified database + const User1 = + databaseName === "test" + ? connection.models.User || connection.model("User", testUserSchema, "users") + : connection.models.User || connection.model("User", devUserSchema, "users"); + + try { + // Find user by email in the specified database + const user = await User1.findOne({ email: userEmail }); + if (!user) { + return NextResponse.json({ message: "User not found" }, { status: 404 }); + } + + return NextResponse.json(user, { status: 200 }); + } catch (error) { + console.error("Error fetching user data:", error); + return NextResponse.json({ message: "Error fetching user data" }, { status: 500 }); + } +} + +// Update user profile +export async function PUT(req) { + const url = new URL(req.url); + const currentEmail = url.searchParams.get("email"); // Current email to identify the user + const { name, email } = await req.json(); // Extract updated name and email from the body + + if (!currentEmail || (!name && !email)) { + return NextResponse.json( + { message: "Current email, and at least one field to update, are required" }, + { status: 400 } + ); + } + + try { + // Find user by current email and update their name and email + const updatedUser = await User.findOneAndUpdate( + { email: currentEmail }, // Match user by current email + { ...(name && { name }), ...(email && { email }) }, // Update name and/or email if provided + { new: true } // Return the updated user document + ); + + if (!updatedUser) { + return NextResponse.json({ message: "User not found" }, { status: 404 }); + } + + return NextResponse.json(updatedUser, { status: 200 }); + } catch (error) { + // Handle potential unique constraint errors on email + if (error.code === 11000 && error.keyPattern.email) { + return NextResponse.json( + { message: "Email already in use by another user" }, + { status: 409 } + ); + } + + console.error("Error updating user:", error); + return NextResponse.json( + { message: "Error updating user data" }, + { status: 500 } + ); + } +} diff --git a/app/components/Addreview.jsx b/app/components/Addreview.jsx new file mode 100644 index 0000000..9fbed58 --- /dev/null +++ b/app/components/Addreview.jsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { useSession } from "next-auth/react"; + +export default function AddReview({ recipeId,onAdd }) { + const [comment, setComment] = useState(""); + const [rating, setRating] = useState(0); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const { data: session } = useSession(); + const url = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; + + // Function to submit a review + const submitReview = async () => { + if (!session) { + setError("You must be logged in to submit a review."); + setTimeout(() => { + setError(""); + }, 2000); // Reset message after 3 seconds + return; + } + + try { + setError(""); + setSuccess(""); + if (!comment || !rating) { + setError("All fields are required."); + setTimeout(() => { + setError(""); + }, 2000); // Reset message after 3 seconds + return; + } + + const response = await fetch(`${url}/api/addReview`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + recipeId, + comment, + rating, + reviewerName: session.user.name, // Use logged-in user name + }), + }); + + if (!response.ok) throw new Error("Failed to submit review"); + + setSuccess("Review submitted successfully!"); + + setComment(""); + setRating(1); + onAdd(); + setTimeout(() => { + setSuccess(""); + }, 2000); // Reset message after 3 seconds + } catch (error) { + console.error("Error submitting review:", error); + setError("Failed to submit review. Please try again."); + setTimeout(() => { + setError(""); + }, 2000); // Reset message after 3 seconds + } + }; + + return ( +
+

Add a Review

+ {error &&

{error}

} + {success &&

{success}

} + +