diff --git a/.gitignore b/.gitignore index 925ce471..49249a18 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.pnp .pnp.js .yarn/install-state.gz +.next # testing /coverage diff --git a/README.md b/README.md index 0dc9ea2b..c01560cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,419 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +# Project Overview: +Kwamaimai is a dynamic recipe web application for designed users to discover, share, and interact with recipes. The application combines robust search and filtering capabilities with social features, allowing users to rate, review, and save their favorite recipes. + +#### Technology Stack + +##### Frontend: + +- React: +- Next.js + +##### Database: + +- MongoDB: + +##### Backend & API: + +- Next.js API Routes + +#### Features: + +- Search +- Filter +- Sort +- Sign-up/ Log-in +- Favouriting +- Rate & Review +- Edit description +- Edit user profile +- Add to shopping list +- Image Carousel +- Offline usage +- Pagination +- Navigation to recipe details + +# Demo + +Link: [https://kwamaimai.vercel.app/] + +# Installation Instructions + +1. Clone the repository: + git clone + +2. Navigate to the project folder: + ase_group-d + +3. Install Dependencies: + npm install + +4. Run the development server + npm run dev + +# Environmental Variables setup + +Environment variables are crucial for configuring the app for local development. Here's a step-by-step guide to set up and manage them effectively: + +NB! Please use the contact given to get the pairs for the environment variables + +1. In the root directory of your project, look for a file named .env or .env.local +2. If no .env file exists, create one. +3. In the .env.local file, write key-value with pairs. Each line represents one environment variable: + +#### Server Configuration + +- NEXTAUTH_URL=http://localhost:3000 +- NEXTAUTH_SECRET=your_secret_key + +#### Google OAuth + +- AUTH_GOOGLE_ID=your_google_client_id +- AUTH_GOOGLE_SECRET=your_google_client_secret + +#### Database Configuration + +- MONGODB_URI=your_connection_string + +# Project Structure + +root/ +- ├── .next/ +- ├── app/ +- ├── api/ +- │ ├── auth/ +- │ │ └── nextauth/route.js +- │ ├── checkuser/route.js +- │ └── login/route.js +- ├── components/ +- │ ├── ui/ +- │ │ ├── alert.js +- │ │ └── button.js +- │ ├── BackButton.js +- │ ├── Carousel.js +- │ ├── CategoryList.js +- │ ├── CollapsibleSection.js +- │ ├── EditableRecipeDetails.js +- │ ├── FavoriteButton.js +- │ ├── FilterButton.js +- │ ├── Footer.js +- │ ├── Header.js +- │ ├── ImageGallery.js +- │ ├── IngredientList.js +- │ ├── LoadingSpinner.js +- │ ├── Pagination.js +- │ ├── RecipeCarousel.js +- │ ├── RecipeGrid.js +- │ ├── Recipes.js +- │ ├── ReviewsSection.js +- │ ├── SearchBar.js +- │ ├── SortControl.js +- │ ├── StepsDropdown.js +- │ ├── TagList.js +- │ ├── ThemeButton.js +- │ ├── UserModal.js +- │ └── editdetails/ +- │ └── page.js +- ├── fonts/ +- ├── hook/ +- ├── images/ +- ├── Recipe/[id]/ +- ├── styles/global.css +- ├── api.js +- ├── error.js +- ├── layout.js +- ├── loading.js +- ├── not-found.js +- ├── page.js +- ├── providers.js +- ├── review.js +- ├── lib/ +- │ ├── AuthMiddleware.js +- │ ├── utils.js +- ├── components/ +- ├── public/ +- ├── auth.js +- ├── db.js +- ├── next.config.mjs +- ├── packgae-lock.json +- ├── package.json +- ├── postcss.config.mjs +- ├── ReadMe.md +- ├── tailwind.config.js +- ├── testConnection.js + +# API Documentation + +This section outlines the key API endpoints available in the application, their purpose, how to use them, and what responses to expect. + +#### BASE URL + +http://localhost:3000 + +##### API Endpoints + +This section outlines the key API endpoints available in the application, their purpose, how to use them, and what responses to expect. Each endpoint is hosted on /api/ under the Next.js framework, utilizing serverless functions. + +#### 1. Authentication Endpoints + +##### Routes: + +- /api/auth/login +- /api/auth/signup +- /api/auth/logout +- /api/auth/checkuser +- /api/auth/user/[email]/profile + +##### Description + +Handles user authentication processes, including login, signup, logout, and profile verification. + +##### Methods + +- POST: /api/auth/login +- Validates user credentials and returns an authentication token. + +- Request Body (example): + { + "email": "user@example.com", + "password": "securePassword" + } +- Responses: + + - 200 OK: Returns authentication token. + - 400 Unauthorized: User not found or Invalid password. + +- POST /api/auth/register +- Creates a new user account. +- Request Body (example): + { + "full name: "user", + "phone number: "1234567890", + "email": "user@example.com", + "password": "securePassword" + } +- Responses: +- 201 Created: User created successfully. +- 400 Bad Request: User already exists. + +2. Recipe Endpoints + +##### Route + +- /api/recipes +[http://localhost:3000.api/recipes] +##### Description + +Handles CRUD operations for recipes. + +##### Methods + +- GET /api/recipes + - Retrieves a list of recipes. + - Responses: + - 200 OK: List of recipes. +- POST /api/recipes + - Adds a new recipe (requires authentication). + - Request Body (example): + { + "title": "Chocolate Cake", + "ingredients": ["flour", "sugar", "cocoa powder"], + "instructions": "Mix ingredients and bake." + } +- Responses: + - 201 Created: Recipe created successfully. + - 400 Bad Request: Missing or invalid data. + +3. Profile Endpoints + +##### Route + +- /api/profile + +##### Description + +Provides user profile data and allows updates. + +##### Methods + +- GET /api/profile + + - Retrieves the current user’s profile. + - Responses: + - 200 OK: User profile details. + - 401 Unauthorized: No valid authentication token provided. + +- PUT /api/profile + - Updates user information. + - Request Body (example): + { + "name": "Updated Name", + "email": "updated_email@example.com" + } +- Responses: + - 200 OK: Update confirmation. + - 400 Bad Request: Invalid update data. + +4. Favorites Endpoints + +##### Route + +- /api/favorites + +##### Description + +Manages users' favorite recipes. + +##### Methods + +- GET /api/favorites + + - Retrieves the user’s favorite recipes. + - Responses: + - 200 OK: List of favorite recipes. + +- POST /api/favorites + - Adds a recipe to favorites. + - Responses: + - 201 Created: Recipe added to favorites. +- DELETE /api/favorites + - Removes a recipe from favorites. + - Responses: + - 200 OK: Recipe removed from favorites. + +5. Reviews Endpoints + +##### Route + +- /api/reviews + +##### Description + +Handles user reviews for recipes. + +##### Methods + +- GET /api/reviews?recipeId= + + - Retrieves reviews for a specific recipe. + - Responses: + - 200 OK: List of reviews. + +- POST /api/reviews + - Adds a new review to a recipe. + - Request Body (example): + { + "recipeId": "123", + "review": "Delicious recipe!" + } +- Responses: + - 201 Created: Review added successfully. + +6. Categories Endpoints + +##### Route + +- /api/recipes/categories +[http://localhost:3000/api/recipes/categories] + +##### Description + +Retrieves available recipe categories. + +##### Methods + +- GET /api/recipes/categories + - Fetches a list of recipe categories. + - Responses: + - 200 OK: List of categories. + +7. Ingredients Endpoints + ##### Route + - /api/ingredients + ##### Description + Provides ingredient data, including substitutes and availability. + +##### Methods + +- GET /api/ingredients + - Fetches ingredient details. + - Responses: + - 200 OK: List of ingredients. + +8. Recommendations Endpoints + ##### Route + - /api/recommendations + ##### Description + Suggests recipes based on user preferences, history, or trending recipes. + +##### Methods + +- GET /api/recommendations + - Retrieves personalized recipe recommendations. + - Responses: + - 200 OK: List of recommended recipes. + +9. Tags Endpoints + ##### Route + - /api/tags + ##### Description + Manages recipe tags for better categorization and filtering. + +##### Methods + +- GET /api/tags + - Retrieves available tags. + - Responses: + - 200 OK: List of tags. + +10. Shopping List Endpoints + ##### Route + - /api/shopping-list + ##### Description + Handles users' shopping lists for recipes. + +##### Methods + +- GET /api/shopping-list + - Retrieves the shopping list. + - Responses: + - 200 OK: List of items. +- POST /api/shopping-list + - Adds an item to the shopping list. + - Responses: + - 201 Created: Item added successfully. + +11. Instructions Endpoints + ##### Route + - /api/instructions + ##### Description + Provides step-by-step instructions for recipes. + +##### Methods + +- GET /api/instructions?recipeId= + - Retrieves instructions for a specific recipe. + - Responses: + - 200 OK: Recipe instructions. + +12. Manifest + ##### Route + - /api/manifest + ##### Description + Fetches the application manifest for metadata and integration. + +##### Methods + +- GET /api/manifest + - Retrieves the manifest. + - Responses: + - 200 OK: Manifest data. + +# Contact Information + +Email: [groupd.ase@gmail.com] + + diff --git a/app/Recipe/[id]/loading.js b/app/Recipe/[id]/loading.js new file mode 100644 index 00000000..c6e04493 --- /dev/null +++ b/app/Recipe/[id]/loading.js @@ -0,0 +1,6 @@ +import LoadingSpinner from "../../components/loadingSpinner" + +export default function Loading() { + + return +} \ No newline at end of file diff --git a/app/Recipe/[id]/page.js b/app/Recipe/[id]/page.js index b7443e15..8ba133db 100644 --- a/app/Recipe/[id]/page.js +++ b/app/Recipe/[id]/page.js @@ -1,34 +1,37 @@ -import { Suspense } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import BackButton from '../../components/BackButton'; +import { Suspense } from "react"; +import Image from "next/image"; +import BackButton from "../../components/ui/BackButton"; import { fetchRecipeById } from '../../api'; import ImageGallery from '../../components/ImageGallery'; -import CollapsibleSection from '../../components/CollapsibleSection'; -import dynamic from 'next/dynamic'; -import Loading from '../../components/loading'; // Importing the Loading component +import Loading from './loading'; +import EditableRecipeDetails from '../../components/EditableRecipeDetails' +import ReviewsSection from '../../components/ReviewsSection'; +import AllergensSection from '../../components/AllergensSection'; +import VoiceAssistant from "../../components/VoiceAssistant"; +import RecipeIngredientsSelector from '../../components/RecipeIngredientsSelector' +import Link from 'next/link'; -//Generate metadata for the recipe page dynamically +// Generate metadata for the recipe page dynamically export async function generateMetadata({ params }) { const { id } = params; - const { recipe, error } = await fetchRecipeById(id); + const recipe = await fetchRecipeById(id); - if (error || !recipe) { + if (!recipe) { return { - title: 'Recipe not found', - description: 'Error occurred while fetching the recipe.' + title: "Recipe not found", + description: "Error occurred while fetching the recipe.", }; } return { - title: recipe.title || 'Untitled Recipe', - description: recipe.description || 'No description available.', + title: recipe.title || "Untitled Recipe", + description: recipe.description || "No description available.", openGraph: { - title: recipe.title || 'Untitled Recipe', - description: recipe.description || 'No description available.', - images: recipe.images?.[0] || '/kwaMai.jpg', - type: 'article' - } + title: recipe.title || "Untitled Recipe", + description: recipe.description || "No description available.", + images: recipe.images?.[0] || "/0.png", + type: "article", + }, }; } @@ -39,19 +42,13 @@ export default async function RecipePage({ params }) { let load = true; let error = null; - try{ + try { recipe = await fetchRecipeById(id); - }catch(error){ - console.error('Error fetching recipe:', error); - error = 'Failed to load recipe data.'; - }finally{ + } catch (error) { + console.error("Error fetching recipe:", error); + error = "Failed to load recipe data."; + } finally { load = false; - }; - - if(load){ -
- {/* Use your Loading component here */} -
} if (error) { @@ -59,110 +56,129 @@ export default async function RecipePage({ params }) { } return ( -
-
- {/* Back Button */} -
- -
- -
- {/* Image Section */} -
- }> {/* Use the Loading component here */} - {recipe.images && recipe.images.length > 0 ? ( - - ) : recipe.images?.[0] ? ( - {recipe.title + }> +
+ {/* Back Button */} +
+ +
+ +
+ {/* Image and Details Section */} +
+ {/* Left Side: Image */} +
+ {recipe.images && recipe.images.length > 0 ? ( + + ) : recipe.images?.[0] ? ( + {recipe.title + ) : ( +
+

No image available

+
+ )} +
+ + {/* Right Side: Details */} +
+

+ {recipe.title || 'Untitled Recipe'} +

+
+ {recipe.tags?.map((tag, index) => ( + + {tag} + + ))} +
+ + {/* Allergens Section */} + + {/* Editable Recipe Details */} + - ) : ( -
-

No image available

+ + {/* Nutrition info */} +
+

Nutrition

+
    + {Object.entries(recipe.nutrition || {}).map(([key, value], index) => ( +
  • + {key}: {value} +
  • + ))} +
- )} - -
+
+
+ + {/* Ingredients and Instructions Section*/} - {/* Title and Tags Section */} -
-

- {recipe.title || 'Untitled Recipe'} -

-
- {recipe.tags?.map((tag, index) => ( - - {tag} - - ))} +
+
+

Ingredients

+ +
+
+

Instructions

+
    + {Object.entries(recipe.instructions || {}).map(([key, value], index) => ( +
  1. + {key}: {value} +
  2. + ))} +
+ +
-
- {/* Collapsible Sections */} - - - - {Object.entries(recipe.ingredients || {}).map(([key, value], index) => ( -
  • - {key}: {value} -
  • - ))} - - } - defaultOpen={true} - /> - - - {Object.entries(recipe.nutrition || {}).map(([key, value], index) => ( -
  • - {key}: {value} -
  • - ))} - - } - defaultOpen={true} - /> - - - - {/* Footer Information */} -
    -

    - Published: {new Date(recipe.published).toDateString()} -

    -

    - Prep Time: {recipe.prep} minutes -

    -

    - Cook Time: {recipe.cook} minutes -

    -

    - Servings: {recipe.servings} -

    -

    - Category: {recipe.category} -

    -
    -
    -
    -
    + {/* Reviews Section */} +
    +

    Reviews

    + +
    + + {/* Footer Information */} + < div className="mt-8 bg-white dark:bg-[#1c1d02] p-6 rounded-xl shadow-xl" > +

    + Published: {new Date(recipe.published).toDateString()} +

    +

    + Prep Time: {recipe.prep} minutes +

    +

    + Cook Time: {recipe.cook} minutes +

    +

    + Servings: {recipe.servings} +

    +

    + Category: {recipe.category} +

    +
    + + +

    + View Shopping List +

    + +
    +
    + +
    + ); } + + diff --git a/app/actions/Actions.js b/app/actions/Actions.js new file mode 100644 index 00000000..4296b02c --- /dev/null +++ b/app/actions/Actions.js @@ -0,0 +1,46 @@ +'use server'; + +import webpush from 'web-push'; + +webpush.setVapidDetails( + 'mailto:your-email@example.com', // Remove angle brackets + process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY +); + +let subscription = null; + +export async function subscribeUser(sub) { + subscription = sub; + // In a production environment, you would want to store the subscription in a database + // Example: await db.subscriptions.create({ data: sub }) + return { success: true }; +} + +export async function unsubscribeUser() { + subscription = null; + // In a production environment, you would want to remove the subscription from the database + // Example: await db.subscriptions.delete({ where: { ... } }) + return { success: true }; +} + +export async function sendNotification(message) { + if (!subscription) { + throw new Error('No subscription available'); + } + + try { + await webpush.sendNotification( + subscription, + JSON.stringify({ + title: 'Test Notification', + body: message, + icon: '/icon.png', + }) + ); + return { success: true }; + } catch (error) { + console.error('Error sending push notification:', error); + return { success: false, error: 'Failed to send notification' }; + } +} diff --git a/app/api.js b/app/api.js index 5c96618b..6b4a603e 100644 --- a/app/api.js +++ b/app/api.js @@ -8,11 +8,22 @@ const API_BASE_URL = process.env.API_BASE_URL; * @param {number} [page] - The current page number (optional). * @returns {Promise} - Returns a promise that resolves with the recipe data, or throws an error if the request fails. */ -export async function fetchRecipes(limit = 20, page,search='') { +export async function fetchRecipes(limit = 20, page, search, tags, category, ingredients, instructions, sort, order) { + if (instructions == 0) { + instructions = ''; + } + // Construct query string with limit and page parameters const query = new URLSearchParams({ limit, // Set the limit of items per page - ...(page && { page }) // Conditionally add 'page' to the query if it's provided + ...(page && { page }), // Conditionally add 'page' to the query if it's provided + ...(search && { search }), + ...(tags && { tags }), // Conditionally add 'tags' to the query if it + ...(category && { category }), // Conditionally add 'category' to the query if it's provided + ...(ingredients && { ingredients }), // Conditionally add 'ingredients' to the query if it's provided + ...(instructions && { instructions }), // Conditionally add 'instructions' to the query if it's provided + ...(order && {order}), + ...(sort && {sort}) }).toString(); try { @@ -29,7 +40,7 @@ export async function fetchRecipes(limit = 20, page,search='') { return data; // Return the fetched recipe data } catch (error) { - return error; // Return the error if the request fails + throw error; // Throw the error if the request fails } } @@ -57,3 +68,116 @@ export async function fetchRecipeById(id) { throw error; // Throw the error if the request fails } } + +/** + * Fetches all reviews for a specific recipe. + * + * @param {string} recipeId - The unique ID of the recipe to fetch reviews for. + * @param {Object} [sortOptions] - Sorting options (e.g., { sortBy: 'rating', order: 'asc' }). + * @returns {Promise} - Returns a promise that resolves with an array of reviews. + */ +export async function fetchReviewsForRecipe(recipeId, sortOptions = { sortBy: 'createdAt', order: 'desc' }) { + const query = new URLSearchParams({ + sortBy: sortOptions.sortBy, + order: sortOptions.order, + }).toString(); + + try { + // Make a GET request to fetch reviews for a specific recipe + const response = await fetch(`${API_BASE_URL}/api/recipes/${recipeId}/reviews?${query}`); + + // Check if the response is successful + if (!response.ok) { + throw new Error('Failed to fetch reviews'); + } + + const data = await response.json(); + return data; // Return the reviews data + + } catch (error) { + throw error; + } +} + +/** + * Creates a new review for a specific recipe. + * + * @param {string} recipeId - The ID of the recipe the review is for. + * @param {Object} reviewData - The review data (e.g., { rating, comment, userId }). + * @returns {Promise} - Returns a promise that resolves with the newly created review. + */ +export async function createReview(recipeId, reviewData) { + try { + const response = await fetch(`${API_BASE_URL}/api/recipes/${recipeId}/reviews`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(reviewData), + }); + + if (!response.ok) { + throw new Error('Failed to create review'); + } + + const data = await response.json(); + return data; // Return the created review + + } catch (error) { + throw error; + } +} + +/** + * Updates an existing review for a specific recipe. + * + * @param {string} recipeId - The ID of the recipe the review is for. + * @param {string} reviewId - The ID of the review to update. + * @param {Object} updateData - The data to update the review (e.g., { rating, comment }). + * @returns {Promise} - Returns a promise that resolves with the updated review. + */ +export async function updateReview(recipeId, reviewId, updateData) { + try { + const response = await fetch(`${API_BASE_URL}/api/recipes/${recipeId}/reviews/${reviewId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + throw new Error('Failed to update review'); + } + + const data = await response.json(); + return data; // Return the updated review + + } catch (error) { + throw error; + } +} + +/** + * Deletes a review for a specific recipe. + * + * @param {string} recipeId - The ID of the recipe the review is for. + * @param {string} reviewId - The ID of the review to delete. + * @returns {Promise} - Returns a promise that resolves when the review is deleted. + */ +export async function deleteReview(recipeId, reviewId) { + try { + const response = await fetch(`${API_BASE_URL}/api/recipes/${recipeId}/reviews/${reviewId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete review'); + } + + return; // No data returned upon successful deletion + + } catch (error) { + throw error; + } +} diff --git a/app/api/auth/[...nextauth]/route.js b/app/api/auth/[...nextauth]/route.js new file mode 100644 index 00000000..eab1b1ee --- /dev/null +++ b/app/api/auth/[...nextauth]/route.js @@ -0,0 +1,89 @@ +import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import bcrypt from "bcryptjs"; +import connectToDatabase from "../../../../db"; +import { OAuth2Client } from "google-auth-library"; + +const client = new OAuth2Client(process.env.AUTH_GOOGLE_ID); + +export const authOptions = { + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const db = await connectToDatabase(); + const user = await db.collection("users").findOne({ email: credentials.email }); + + if (user && await bcrypt.compare(credentials.password, user.password)) { + return { + id: user._id.toString(), + email: user.email, + name: user.name + }; + } + return null; + }, + }), + GoogleProvider({ + clientId: process.env.AUTH_GOOGLE_ID, + clientSecret: process.env.AUTH_GOOGLE_SECRET, + }), + ], + session: { strategy: "jwt" }, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.email = user.email; + token.name = user.name; + } + return token; + }, + async session({ session, token }) { + if (token) { + session.user.id = token.id; + session.user.email = token.email; + session.user.name = token.name; + } + return session; + }, + async signIn({ user, account }) { + if (account.provider === "google") { + const { id_token } = account; + try { + // Verify the Google ID token + const ticket = await client.verifyIdToken({ + idToken: id_token, + audience: process.env.AUTH_GOOGLE_ID, + }); + const payload = ticket.getPayload(); + + const db = await connectToDatabase(); + const existingUser = await db.collection("users").findOne({ email: payload.email }); + + if (!existingUser) { + await db.collection("users").insertOne({ + email: payload.email, + name: payload.name, + image: payload.picture, + createdAt: new Date(), + }); + } + } catch (error) { + console.error("Google ID token verification failed:", error); + return false; // Prevent sign-in if verification fails + } + } + return true; + }, + }, +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST } diff --git a/app/api/auth/checkuser/route.js b/app/api/auth/checkuser/route.js new file mode 100644 index 00000000..50b478d1 --- /dev/null +++ b/app/api/auth/checkuser/route.js @@ -0,0 +1,30 @@ +import connectToDatabase from "../../../../db"; + +/** + * Checks if a user exists based on the email provided in the request body. + * + * @async + * @function POST + * @param {Request} req - The incoming HTTP request with JSON body containing the email. + * @returns {Promise} A promise that resolves to a Response object: + * - 200 if the user exists. + * - 404 if the user is not found. + */ +export async function POST(req) { + try { + const { email } = await req.json(); + const db = await connectToDatabase(); + + // Check if the user exists + const user = await db.collection("users").findOne({ email }); + + if (user) { + return new Response(JSON.stringify({ message: "User exists" }), { status: 200 }); + } else { + return new Response(JSON.stringify({ message: "User not found" }), { status: 404 }); + } + } catch (error) { + console.error("Error in checking user existence:", error); + return new Response(JSON.stringify({ message: "An error occurred" }), { status: 500 }); + } +} diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js new file mode 100644 index 00000000..fc19cf3e --- /dev/null +++ b/app/api/auth/login/route.js @@ -0,0 +1,54 @@ +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { serialize } from "cookie"; // Import serialize as a named import +import connectToDatabase from "../../../../db"; + +/** + * Handles the POST request to log in a user. + * This function verifies the user's email and password against the database. + * If valid, it generates a JWT and sets it in a cookie. + * + * @async + * @function POST + * @param {Request} req - The incoming HTTP request containing user credentials. + * @returns {Promise} A promise that resolves to a Response object. + * - If the user is not found, a 400 status with an error message is returned. + * - If the password is invalid, a 400 status with an error message is returned. + * - If the login is successful, a 200 status with a success message is returned, + * along with a Set-Cookie header containing the JWT. + */ +export async function POST(req) { + const { email, password } = await req.json(); + const db = await connectToDatabase(); + + const user = await db.collection("users").findOne({ email }); + if (!user) { + return new Response(JSON.stringify({ message: "User not found" }), { status: 400 }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return new Response(JSON.stringify({ message: "Invalid password" }), { status: 400 }); + } + + const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + // Serialize the cookie + const cookie = serialize('authToken', token, { + httpOnly: true, + secure: process.env.NODE_ENV !== 'development', + maxAge: 3600, + sameSite: 'strict', + path: '/', + }); + + return new Response( + JSON.stringify({ message: "Logged in successfully" }), + { + status: 200, + headers: { + "Set-Cookie": cookie, + }, + } + ); +} \ No newline at end of file diff --git a/app/api/auth/logout/route.js b/app/api/auth/logout/route.js new file mode 100644 index 00000000..4089099f --- /dev/null +++ b/app/api/auth/logout/route.js @@ -0,0 +1,31 @@ +import { serialize } from "cookie"; + +/** + * Handles the POST request to log out a user. + * This function clears the authentication token by setting + * the `authToken` cookie to an empty value and an expiration date in the past. + * + * @async + * @function POST + * @returns {Promise} A promise that resolves to a Response object containing + * a JSON message indicating successful logout. + * The response includes a Set-Cookie header to clear the + * `authToken` cookie. + */ +export async function POST() { + return new Response( + JSON.stringify({ message: "Logged out successfully" }), + { + status: 200, + headers: { + "Set-Cookie": serialize("authToken", "", { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + maxAge: -1, // Cookie expires immediately + sameSite: "strict", + path: "/", + }), + }, + } + ); +} \ No newline at end of file diff --git a/app/api/auth/signup/route.js b/app/api/auth/signup/route.js new file mode 100644 index 00000000..56040f14 --- /dev/null +++ b/app/api/auth/signup/route.js @@ -0,0 +1,37 @@ +import bcrypt from "bcryptjs"; +import connectToDatabase from "../../../../db"; + +/** + * Handles the POST request to create a new user. + * This function checks if a user with the provided email already exists. + * If not, it hashes the provided password and inserts the new user into the database. + * + * @async + * @function POST + * @param {Request} request - The incoming HTTP request containing user data. + * @returns {Promise} A promise that resolves to a Response object. + * The response indicates the success or failure of the user creation process. + * - If the user already exists, a 400 status with an error message is returned. + * - If the user is created successfully, a 201 status with a success message is returned. + */ +export async function POST(request) { + const { fullName, email, phoneNumber, password } = await request.json(); + const db = await connectToDatabase(); + + const existingUser = await db.collection("users").findOne({ email }); + if (existingUser) { + return new Response(JSON.stringify({ message: "User already exists" }), { status: 400 }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + await db.collection("users").insertOne({ fullName, email, phoneNumber, password: hashedPassword }); + + return new Response(JSON.stringify({ message: "User created successfully" }), { status: 201 }); +} + + + + + + + diff --git a/app/api/auth/user/[email]/profile/route.js b/app/api/auth/user/[email]/profile/route.js new file mode 100644 index 00000000..c4d3146d --- /dev/null +++ b/app/api/auth/user/[email]/profile/route.js @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import connectToDatabase from '../../../../../../db'; + +export async function GET(req, { params }) { + try { + const { email } = params; + + if (!email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + // Fetch user profile by email + const user = await usersCollection.findOne({ email: email }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Return the user profile data + return NextResponse.json(user, { status: 200 }); + } catch (error) { + console.error('Error fetching user data:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function PUT(req) { + try { + const { fullName, email, phoneNumber, password } = await req.json(); + + if (!email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + // Update user profile with provided data + const updateResult = await usersCollection.updateOne( + { email: email }, + { $set: { fullName, phoneNumber, password } } + ); + + if (updateResult.modifiedCount === 0) { + return NextResponse.json({ error: 'User not found or no changes made' }, { status: 404 }); + } + + return NextResponse.json({ message: 'Profile updated successfully' }, { status: 200 }); + } catch (error) { + console.error('Error updating profile:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + diff --git a/app/api/favorites/route.js b/app/api/favorites/route.js new file mode 100644 index 00000000..c77b812c --- /dev/null +++ b/app/api/favorites/route.js @@ -0,0 +1,120 @@ +import { NextResponse } from 'next/server'; +import connectToDatabase from '../../../db'; + +export async function POST(req) { + try { + const { email, recipeId } = await req.json(); + + if (!email || !recipeId) { + return NextResponse.json({ error: 'Email and recipeId are required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + // First check if user exists, if not create them with empty favorites + const user = await usersCollection.findOne({ email: email }); + if (!user) { + await usersCollection.insertOne({ + email: email, + favorites: [] + }); + } + + // Update the user's favorites array + const updateResult = await usersCollection.updateOne( + { email: email }, + { $addToSet: { favorites: recipeId } }, + { upsert: true } // Changed to true to create user if doesn't exist + ); + + if (updateResult.modifiedCount === 0 && updateResult.upsertedCount === 0) { + // If recipe is already in favorites + return NextResponse.json({ message: 'Recipe already in favorites' }, { status: 200 }); + } + + return NextResponse.json({ message: 'Favorite added successfully' }, { status: 200 }); + } catch (error) { + console.error('Error adding favorite:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function DELETE(req) { + try { + const { email, recipeId } = await req.json(); + + if (!email || !recipeId) { + return NextResponse.json({ error: 'Email and recipeId are required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + const updateResult = await usersCollection.updateOne( + { email: email }, + { $pull: { favorites: recipeId } } + ); + + if (updateResult.modifiedCount === 0) { + // Check if user exists + const user = await usersCollection.findOne({ email: email }); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + // If user exists but recipe wasn't in favorites + return NextResponse.json({ message: 'Recipe was not in favorites' }, { status: 200 }); + } + + return NextResponse.json({ message: 'Favorite removed successfully' }, { status: 200 }); + } catch (error) { + console.error('Error removing favorite:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function GET(req) { + try { + // Get email from searchParams + const { searchParams } = new URL(req.url); + const email = searchParams.get('email'); + + if (!email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const usersCollection = db.collection('users'); + + // Fetch the user document + const user = await usersCollection.findOne( + { email: email }, + { projection: { favorites: 1, _id: 0 } } + ); + + if (!user) { + // Create new user with empty favorites if doesn't exist + await usersCollection.insertOne({ + email: email, + favorites: [] + }); + return NextResponse.json({ favorites: [], favoritesCount: 0 }, { status: 200 }); + } + + // Ensure favorites exists + const favorites = user.favorites || []; + + // Fetch the details of all favorited recipes from the 'recipes' collection + const recipesCollection = db.collection('recipes'); + const favoritedRecipes = await recipesCollection.find({ _id: { $in: favorites } }).toArray(); + + return NextResponse.json({ + favorites: favoritedRecipes, + favoritesCount: favoritedRecipes.length + }, { status: 200 }); + + } catch (error) { + console.error('Error retrieving favorites:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/manifest/route.js b/app/api/manifest/route.js new file mode 100644 index 00000000..a9d9a078 --- /dev/null +++ b/app/api/manifest/route.js @@ -0,0 +1,24 @@ +export async function GET(request) { + const manifest = { + name: 'Kwamai Eatery', + short_name: 'Next.js App', + description: 'Next.js App', + start_url: '/', + display: 'standalone', + background_color: '#fff', + theme_color: '#fff', + icons: [ + { + src: '/0.png', + sizes: '192x192', + type: 'image/png', + }, + ], + }; + return new Response(JSON.stringify(manifest), { + headers: { + 'Content-Type': 'application/json', + }, + }); + } + \ No newline at end of file diff --git a/app/api/recipes/[id]/allergens/route.js b/app/api/recipes/[id]/allergens/route.js new file mode 100644 index 00000000..c7e7b625 --- /dev/null +++ b/app/api/recipes/[id]/allergens/route.js @@ -0,0 +1,131 @@ +import { NextResponse } from "next/server"; +import connectToDatabase from "../../../../../db"; + +export async function GET(request, { params }) { + try { + const db = await connectToDatabase(); + const { id } = params; + + // Validate the id + if (!id) { + return NextResponse.json( + { error: 'Recipe ID is required' }, + { status: 400 } + ); + } + + // Fetch common allergens from the database + const allergensCollection = db.collection('commonAllergens'); + const commonAllergens = await allergensCollection.find({}).toArray(); + + // Extract allergen names, with a fallback + const allergenNames = commonAllergens.length > 0 + ? commonAllergens.map(allergen => allergen.name) + : [ + "nut", "soy", "wheat", "milk", "cheese", "cream", "egg", "fish", + "sesame", "mustard", "corn", "clam", "mussel", "oyster", "celery" + ]; + + const recipe = await db.collection('recipes').findOne( + { _id: id }, + { projection: { ingredients: 1, allergens: 1 } } + ); + + if (!recipe) { + return NextResponse.json( + { error: 'Recipe not found' }, + { status: 404 } + ); + } + + // Existing allergens from the recipe + const existingAllergens = recipe.allergens || []; + + // Match ingredients with common allergens if ingredients exist + const detectedAllergens = recipe.ingredients + ? matchIngredientsWithAllergens(recipe.ingredients, allergenNames) + : []; + + // Combine existing and detected allergens, removing duplicates + const combinedAllergens = Array.from( + new Set([...existingAllergens, ...detectedAllergens]) + ); + + return NextResponse.json({ allergens: combinedAllergens }); + } catch (error) { + console.error('Error fetching allergens:', error); + return NextResponse.json( + { error: 'Failed to fetch allergens', details: error.message }, + { status: 500 } + ); + } +} + +/** + * Match recipe ingredients with known allergens + * @param {Object} ingredients - Recipe ingredients object + * @param {string[]} allergens - List of common allergens + * @returns {string[]} Detected allergens in the recipe + */ +function matchIngredientsWithAllergens(ingredients, allergens) { + if (!ingredients || typeof ingredients !== 'object') return []; + + // Normalize allergens to lowercase for case-insensitive matching + const normalizedAllergens = allergens.map(a => a.toLowerCase()); + + const detectedAllergens = new Set(); + + // Iterate through ingredients + Object.entries(ingredients).forEach(([ingredientName, quantity]) => { + const normalizedIngredient = ingredientName.toLowerCase(); + + // Check if any allergen is found in the ingredient name + normalizedAllergens.forEach(allergen => { + if (normalizedIngredient.includes(allergen)) { + detectedAllergens.add(allergen); + } + }); + }); + + // Convert Set back to array with proper capitalization + return Array.from(detectedAllergens).map(a => + a.charAt(0).toUpperCase() + a.slice(1) + ); +} + +// Add/update allergens to a recipe +export async function PUT(request, { params }) { + try { + const db = await connectToDatabase(); + const { id } = params; + const { allergens } = await request.json(); + + // Validate inputs + if (!id) { + return NextResponse.json( + { error: 'Recipe ID is required' }, + { status: 400 } + ); + } + + const result = await db.collection('recipes').updateOne( + { _id: id }, + { $set: { allergens } } + ); + + if (result.matchedCount === 0) { + return NextResponse.json( + { error: 'Recipe not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true, allergens }); + } catch (error) { + console.error('Error updating allergens:', error); + return NextResponse.json( + { error: 'Failed to update allergens', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/recipes/[id]/reviews/route.js b/app/api/recipes/[id]/reviews/route.js new file mode 100644 index 00000000..cc313fed --- /dev/null +++ b/app/api/recipes/[id]/reviews/route.js @@ -0,0 +1,138 @@ +//api/recipes/[id]/reviews/routes.js + +import { NextResponse } from 'next/server'; +import connectToDatabase from '../../../../../db'; +import { + createReview, + updateReview, + deleteReview, + getRecipeReviews +} from '../../../../review'; + +async function validateRecipe(recipeId) { + const db = await connectToDatabase(); + + if (!recipeId) { + throw new Error('Recipe ID is required'); + } + + const recipe = await db.collection('recipes').findOne({ _id: recipeId }); + if (!recipe) { + throw new Error('Recipe not found'); + } + + return recipe; +} + +// Create review +export async function POST(request) { + try { + //const body2 = await request.text(); // Get the raw body as text for debugging + //console.log("Raw request body:", body2); // Log the body for debugging + + const body = await request.json(); // Parse JSON from the request body + + // Destructure and log each field individually + const {rating, comment, recipeId } = body; + console.log("recipeId:", recipeId); + console.log("rating:", rating); + console.log("comment:", comment); + + + // Validate the recipe ID + await validateRecipe(recipeId); + + // Connect to database and create review + const db = await connectToDatabase(); + const review = await createReview(db, body); + + return NextResponse.json(review, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: error.message.includes('not found') ? 404 : 500 } + ); + } +} + +// Update review +// Update review +export async function PUT(request) { + try { + // Retrieve `editId` from the query parameters + const url = new URL(request.url); + const reviewId = url.searchParams.get('editId'); + + if (!reviewId) { + return NextResponse.json( + { error: 'Review ID is required for update' }, + { status: 400 } + ); + } + + const body = await request.json(); + + const db = await connectToDatabase(); + await updateReview(db, reviewId, body); + + return NextResponse.json({ message: 'Review updated successfully' }); + } catch (error) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} +// Delete review +export async function DELETE(request) { + try { + const url = new URL(request.url); + const deleteId = url.searchParams.get('deleteId'); + + if (!deleteId) { + return NextResponse.json({ error: 'deleteId query parameter is required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + await deleteReview(db, deleteId); + + return NextResponse.json({ message: 'Review deleted successfully' }); + } catch (error) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} + + +// Get reviews for a recipe +export async function GET(request, { params }) { + try { + const { id } = params; + const recipeId = id; + await validateRecipe(recipeId); + + console.log('Did we get ValidateRecipe?') + + // Get query parameters + const searchParams = request.nextUrl.searchParams; + const sortOptions = { + sortBy: searchParams.get('sortBy') || 'date', + order: searchParams.get('order') || 'desc' + }; + + const db = await connectToDatabase(); + const reviews = await getRecipeReviews(db, recipeId, sortOptions); + + return NextResponse.json(reviews); + } catch (error) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { + status: error.message.includes('not found') ? 404 : + error.message.includes('required') ? 400 : 500 + } + ); + } +} diff --git a/app/api/recipes/[id]/route.js b/app/api/recipes/[id]/route.js index bd944455..158d8b9e 100644 --- a/app/api/recipes/[id]/route.js +++ b/app/api/recipes/[id]/route.js @@ -27,6 +27,7 @@ export async function GET(req, { params }) { throw new Error('Failed to get database connection'); } + // Attempt to find a single recipe by its ID in the 'recipes' collection const recipe = await db.collection('recipes').findOne({ _id: id }); diff --git a/app/api/recipes/[id]/update/route.js b/app/api/recipes/[id]/update/route.js new file mode 100644 index 00000000..b0abff80 --- /dev/null +++ b/app/api/recipes/[id]/update/route.js @@ -0,0 +1,118 @@ +import connectToDatabase from '../../../../../db'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../../auth/[...nextauth]/route'; +import { NextResponse } from 'next/server'; + +/** + * Update a recipe's description in the MongoDB database. + * + * This function handles PATCH requests to update a recipe document's description + * based on the provided `id` parameter. It verifies if the user is logged in, + * validates the new description, and updates the recipe with the new description, + * the ID of the editing user, and a timestamp. + * + * @param {Request} req - The HTTP request object. + * @param {Object} param1 - An object containing the `params` object. + * @param {Object} param1.params - Parameters from the request URL. + * @param {string} param1.params.id - The ID of the recipe to update. + * + * @returns {Promise} A Response object with success or error messages. + */ +export async function PATCH(request, { params }) { + try { + //gets url params + const url = new URL(request.url); + + // Parse the request body + let body; + try { + body = await request.json(); + console.log("Request Body:", body); + } catch (error) { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 } + ); + } + //get new desciption input + const { description } = body; + + if (!description?.trim()) { + return NextResponse.json( + { error: 'Description is required' }, + { status: 400 } + ); + } + + const db = await connectToDatabase(); + + // Validate recipe ID as a string + if (!params.id || typeof params.id !== 'string') { + return NextResponse.json( + { error: 'Invalid recipe ID format' }, + { status: 400 } + ); + } + + const recipeId = params.id; // Use the string ID directly + + console.log("Looking for recipe with ID:", recipeId); +//_________________________________________________________________________________________ + + + + // Log the existing recipes in the collection for debugging// Logs new Info into DataBase with email + const recipe = await db.collection('recipes').findOne({ _id: recipeId }); + if (!recipe) { + return NextResponse.json( + { error: 'Recipe not found' }, + { status: 404 } + ); + } + + + // Update recipe with all required fields in one operation + const emailData= url.searchParams.get('email') + console.log("Description to update:", description); + console.log("Email of editor:", emailData); + const result = await db.collection('recipes').findOneAndUpdate( + { _id: recipeId }, + { + $set: { + description: description.trim(), + lastEditedBy: emailData, + lastEditedAt: new Date(), + } + }, + { returnDocument: 'after' } // This returns the updated document + ); +//______________________________________________________________________________ + + + + + // Handle recipe not found or no update + if (!result) { + return NextResponse.json( + { error: 'Recipe not found or no changes made' }, + { status: 404 } + ); + } + + + // Return success response with updated data + return NextResponse.json({ + message: 'Recipe updated successfully', + description: result.description, + lastEditedBy: result.lastEditedBy, + lastEditedAt: result.lastEditedAt + }); + + } catch (error) { + console.error('Recipe update failed:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/recipes/categories/route.js b/app/api/recipes/categories/route.js new file mode 100644 index 00000000..30ae8615 --- /dev/null +++ b/app/api/recipes/categories/route.js @@ -0,0 +1,31 @@ +import connectToDatabase from "../../../../db"; + +/** + * Handles GET requests to fetch distinct categories from the categories collection. + * + * @param {Request} req - The request object representing the incoming HTTP request. + * @returns {Response} A JSON response containing an array of distinct categories or an error message. + */ +export async function GET(req) { + try { + const db = await connectToDatabase(); + + {/** fetch categories from the categories collection */} + const categories = await db.collection('categories').distinct('categories'); + + {/**return a successful response with the categories in JSON format*/} + return new Response(JSON.stringify(categories), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + {/**log the error message for debugging*/} + console.error('Error fetching categories:', error.message); + + {/**return an error response with status 500*/} + return new Response(JSON.stringify({ error: 'Failed to fetch categories' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/app/api/recipes/ingredients/route.js b/app/api/recipes/ingredients/route.js new file mode 100644 index 00000000..a25951cb --- /dev/null +++ b/app/api/recipes/ingredients/route.js @@ -0,0 +1,26 @@ +import connectToDatabase from "../../../../db"; +export async function GET() { + try { + const db = await connectToDatabase(); + + + const ingredients = await db.collection('recipes').aggregate([ + { $project: { ingredients: { $objectToArray: "$ingredients" } } }, // Convert the ingredients object to an array of key-value pairs + { $unwind: "$ingredients" }, // Deconstruct the array of key-value pairs + { $group: { _id: "$ingredients.k" } }, // Group by the ingredient name (the key) + { $project: { _id: 0, name: "$_id" } } // Project to include only the name field + ]).toArray(); + + // Extract names from the aggregation result + const ingredientNames = ingredients.map(ingredient => ingredient.name); + + console.log("Fetched ingredients:", ingredientNames); + return new Response(JSON.stringify(ingredientNames), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error("Error fetching ingredients:", error); + return new Response(JSON.stringify({ error: 'Failed to fetch ingredients' }), { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/recipes/instructions.js b/app/api/recipes/instructions.js new file mode 100644 index 00000000..da23e929 --- /dev/null +++ b/app/api/recipes/instructions.js @@ -0,0 +1,35 @@ +import { connectToDatabase } from "../../../db"; + +export default async function handler(req, res) { + if (req.method === "POST") { + const { instructions } = req.body; + + if (!instructions || !Array.isArray(instructions)) { + return res.status(400).json({ error: "Instructions must be provided as an array" }); + } + + try { + const { db } = await connectToDatabase(); + const result = await db.collection("instructions").insertOne({ instructions }); + res.status(200).json({ message: "Instructions saved successfully", data: result }); + } catch (error) { + console.error(error); // Log the error for debugging + res.status(500).json({ error: "Failed to save instructions", details: error.message }); + } + } else if (req.method === "GET") { + try { + const { db } = await connectToDatabase(); + const instructions = await db.collection("instructions").find({}).toArray(); + if (instructions.length === 0) { + return res.status(404).json({ message: "No instructions found" }); + } + res.status(200).json({ instructions }); + } catch (error) { + console.error(error); // Log the error for debugging + res.status(500).json({ error: "Failed to retrieve instructions", details: error.message }); + } + } else { + res.setHeader("Allow", ["POST", "GET"]); + res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/app/api/recipes/recommendations/route.js b/app/api/recipes/recommendations/route.js new file mode 100644 index 00000000..a096c470 --- /dev/null +++ b/app/api/recipes/recommendations/route.js @@ -0,0 +1,39 @@ +import connectToDatabase from "../../../../db"; // Adjust the path based on your actual DB utility + +export async function GET() { + try { + const db = await connectToDatabase(); // Connect to the database + + // Perform aggregation to calculate average rating for each recipe + const recipes = await db.collection("recipes").aggregate([ + { + $addFields: { + averageRating: { $avg: "$reviews.rating" }, // Calculate average rating of reviews + }, + }, + { + $match: { + averageRating: { $exists: true, $ne: null }, // Only include recipes with an averageRating + }, + }, + { + $sort: { averageRating: -1 }, // Sort by averageRating in descending order + }, + { + $limit: 10, // Limit to top 10 recipes + }, + ]).toArray(); + + // Return the top 10 recipes with average ratings + return new Response(JSON.stringify(recipes), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Error fetching top-rated recipes:", error); + return new Response( + JSON.stringify({ error: "Failed to fetch top-rated recipes" }), + { status: 500 } + ); + } +} diff --git a/app/api/recipes/route.js b/app/api/recipes/route.js index 1a862191..86d10327 100644 --- a/app/api/recipes/route.js +++ b/app/api/recipes/route.js @@ -1,45 +1,141 @@ -import connectToDatabase from '../../../db.js'; // Adjust the path based on your file structure +import connectToDatabase from '../../../db.js'; +/** + * GET handler for fetching paginated and sorted recipes from the database. + * @param {Request} req - The incoming HTTP request. + * @returns {Response} - JSON response containing paginated recipes and metadata. + */ export async function GET(req) { try { - const db = await connectToDatabase(); // Connect to the database - const recipesCollection = db.collection('recipes'); // Fetch from the 'recipes' collection + const db = await connectToDatabase(); + const recipesCollection = db.collection('recipes'); - // Parse query parameters for pagination and search const url = new URL(req.url); - const page = parseInt(url.searchParams.get('page')) || 1; // Default to page 1 - const limit = Math.min(parseInt(url.searchParams.get('limit')) || 50, 50); // Default to 50, max 50 - const searchTerm = url.searchParams.get('search') || ''; // Get the search term from query - - // Construct the query for text search - const query = searchTerm ? { $text: { $search: searchTerm } } : {}; // Full-text search if there's a search term - - // Calculate the number of documents to skip - const skip = (page - 1) * limit; - - // Fetch the paginated recipes from the collection with the search query - const recipes = await recipesCollection.find(query).skip(skip).limit(limit).toArray(); - console.log('Recipes fetched successfully:', recipes); - - // Count the total number of recipes for pagination info, applying the same search query - const totalRecipes = await recipesCollection.countDocuments(query); - - // Return the data as JSON response, including pagination info - return new Response(JSON.stringify({ - totalRecipes, - totalPages: Math.ceil(totalRecipes / limit), - currentPage: page, - recipes, - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + const searchParams = url.searchParams; + const page = parseInt(searchParams.get("page")) || 1; + const limit = Math.min(parseInt(searchParams.get("limit")) || 50, 50); + const sort = searchParams.get("sort") || ""; + const order = searchParams.get("order")?.toLowerCase() === "desc" ? -1 : 1; + const searchTerm = searchParams.get("search") || ""; + const exactTitle = searchParams.get("exactTitle"); + const category = searchParams.get("category"); + const tags = searchParams.get("tags"); + const ingredients = searchParams.get("ingredients"); + const instructions = parseInt(searchParams.get("instructions")); + + const pipeline = []; + const matchStage = {}; + + // Exact Title Match Logic for Multiple Matches +if (exactTitle) { + const recipes = await recipesCollection.find({ title: exactTitle }).toArray(); + if (recipes.length > 0) { + return new Response( + JSON.stringify({ + totalRecipes: recipes.length, + recipes: recipes, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } else { + return new Response( + JSON.stringify({ + totalRecipes: 0, + recipes: [], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } +} + + // Fallback to text-based and other filters + if (searchTerm) { + matchStage.title = { $regex: searchTerm, $options: "i" }; // Partial match with regex + } + + if (category) { + matchStage.category = { + $regex: new RegExp(`^${category}$`, "i"), + }; + } + + if (tags) { + const tagArray = tags.split(",").map((tag) => tag.trim().toLowerCase()); + matchStage.tags = { + $elemMatch: { $in: tagArray.map((tag) => new RegExp(`^${tag}$`, "i")) }, + }; + } + + if (ingredients) { + const ingredientArray = ingredients.split(",").map((ingredient) => ingredient.trim().toLowerCase()); + matchStage.$and = ingredientArray.map((ingredient) => ({ + [`ingredients.${ingredient}`]: { $exists: true }, + })); + } + + if (!isNaN(instructions)) { + matchStage.instructions = { $size: instructions }; + } + + // Add the $match stage if there are any conditions + if (Object.keys(matchStage).length > 0) { + pipeline.push({ $match: matchStage }); + } + + // Sorting Logic + if (sort === "instructions") { + pipeline.push({ + $addFields: { + instructionsLength: { $size: "$instructions" }, + }, + }); + + pipeline.push({ + $sort: { instructionsLength: order }, + }); + } else if (sort) { + pipeline.push({ + $sort: { [sort]: order }, + }); + } + + // Pagination + pipeline.push({ $skip: (page - 1) * limit }); + pipeline.push({ $limit: limit }); + + // Execute the Aggregation Pipeline + const recipes = await recipesCollection.aggregate(pipeline).toArray(); + const totalRecipes = await recipesCollection.countDocuments(matchStage); + + return new Response( + JSON.stringify({ + totalRecipes, + totalPages: Math.ceil(totalRecipes / limit), + currentPage: page, + recipes, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } catch (error) { - console.error('Error fetching recipes:', error); - // Return error response in case of failure - return new Response(JSON.stringify({ error: 'Failed to fetch recipes' }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); + console.error("Error fetching recipes:", error); + return new Response( + JSON.stringify({ + error: "Failed to fetch recipes", + details: error.message, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); } -} \ No newline at end of file +} diff --git a/app/api/recipes/tags/route.js b/app/api/recipes/tags/route.js new file mode 100644 index 00000000..2a241e37 --- /dev/null +++ b/app/api/recipes/tags/route.js @@ -0,0 +1,18 @@ +import connectToDatabase from "../../../../db"; + +export async function GET() { + try { + const db = await connectToDatabase(); + const tags = await db.collection('recipes').distinct('tags'); + console.log("Fetched tags:", tags); + return new Response(JSON.stringify(tags), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error("Error fetching tags:", error); + return new Response(JSON.stringify({ error: "Failed to fetch tags" }), { + status: 500, + }); +} +} \ No newline at end of file diff --git a/app/api/shoppingList/route.js b/app/api/shoppingList/route.js new file mode 100644 index 00000000..32cc3b48 --- /dev/null +++ b/app/api/shoppingList/route.js @@ -0,0 +1,158 @@ +import { NextResponse } from 'next/server'; +import { ObjectId } from 'mongodb'; +import connectToDatabase from '../../../db.js'; + +// Get a user's shopping list +export async function GET(request) { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('userId'); + if (!userId) { + return NextResponse.json({ success: false, message: 'userId query parameter is required' }, { status: 400 }); + } + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); + const shoppingList = await collection.findOne({ userId }); + if (!shoppingList) { + return NextResponse.json({ success: false, message: 'Shopping list not found' }, { status: 404 }); + } + return NextResponse.json({ success: true, data: shoppingList }); + } catch (error) { + console.error("Error fetching shopping list:", error); // Log the error details + return NextResponse.json({ success: false, error: error.message || 'Internal server error' }, { status: 500 }); + } +} + +// Save or update a user's shopping list +export async function POST(request) { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('userId'); + const itemsParam = url.searchParams.get('items'); + + console.log("URL Parameters:", { userId, itemsParam }); // Log URL parameters + + if (!userId || !itemsParam) { + return NextResponse.json({ success: false, message: 'userId and items are required' }, { status: 400 }); + } + + let newItems; + try { + newItems = JSON.parse(itemsParam); + console.log("Parsed new items:", newItems); // Log parsed new items + } catch (error) { + console.error("Error parsing items JSON:", error); // Log JSON parsing errors + throw new Error("Invalid items JSON"); + } + + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); + const existingList = await collection.findOne({ userId }); + + if (existingList) { + // Append new items to the existing list + const updatedList = await collection.updateOne( + { userId }, + { $push: { items: { $each: newItems } } } + ); + if (!updatedList.matchedCount) { + return NextResponse.json({ success: false, message: 'Failed to update the shopping list' }, { status: 500 }); + } + return NextResponse.json({ success: true, data: updatedList }); + } else { + // Create a new shopping list + const result = await collection.insertOne({ userId, items: newItems }); + + // Safely access the inserted document with improved error handling + if (result && result.insertedId) { + const newList = await collection.findOne({ _id: result.insertedId }); + return NextResponse.json({ success: true, data: newList }, { status: 201 }); + } else { + throw new Error("Failed to retrieve the new shopping list"); + } + } + } catch (error) { + console.error("Error saving shopping list:", error); // Log the error details + return NextResponse.json({ success: false, error: error.message || 'Internal server error' }, { status: 500 }); + } +} + +// Update an item in the shopping list +export async function PATCH(request) { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('userId'); + const itemId = url.searchParams.get('itemId'); + const updatedItemParam = url.searchParams.get('updatedItem'); + + console.log("URL Parameters:", { userId, itemId, updatedItemParam }); // Log URL parameters + + if (!userId || !itemId || !updatedItemParam) { + return NextResponse.json({ success: false, message: 'userId, itemId, and updatedItem are required' }, { status: 400 }); + } + + let updatedItem; + try { + updatedItem = JSON.parse(updatedItemParam); + updatedItem.id = itemId; // Ensure the id field is retained + console.log("Parsed updatedItem:", updatedItem); // Log parsed updatedItem + } catch (error) { + console.error("Error parsing updatedItem JSON:", error); // Log JSON parsing errors + throw new Error("Invalid updatedItem JSON"); + } + + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); + const updatedShoppingList = await collection.updateOne( + { userId, 'items.id': itemId }, + { $set: { 'items.$': updatedItem } } + ); + if (!updatedShoppingList.matchedCount) { + return NextResponse.json({ success: false, message: 'Item not found in shopping list' }, { status: 404 }); + } + return NextResponse.json({ success: true, data: updatedShoppingList }); + } catch (error) { + console.error("Error updating item:", error); // Log the error details + return NextResponse.json({ success: false, error: error.message || 'Internal server error' }, { status: 500 }); + } +} + +// Delete a shopping list or a specific item +export async function DELETE(request) { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('userId'); + const itemId = url.searchParams.get('itemId'); + + console.log("URL Parameters:", { userId, itemId }); // Log URL parameters + + if (!userId) { + return NextResponse.json({ success: false, message: 'userId is required' }, { status: 400 }); + } + + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); + + if (itemId) { + // Delete specific item + const itemRemoved = await collection.updateOne( + { userId }, + { $pull: { items: { id: itemId } } } + ); + if (!itemRemoved.matchedCount) { + return NextResponse.json({ success: false, message: 'Item not found in shopping list' }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } else { + // Delete the entire shopping list + const deletedList = await collection.deleteOne({ userId }); + if (!deletedList.deletedCount) { + return NextResponse.json({ success: false, message: 'Shopping list not found' }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } + } catch (error) { + console.error("Error deleting shopping list or item:", error); // Log the error details + return NextResponse.json({ success: false, error: error.message || 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/shopping_lists/route.js b/app/api/shopping_lists/route.js new file mode 100644 index 00000000..1f7cd365 --- /dev/null +++ b/app/api/shopping_lists/route.js @@ -0,0 +1,71 @@ +import connectToDatabase from '../../../db'; +import { ObjectId } from 'mongodb'; +import { NextResponse } from 'next/server'; + +// Function to handle GET requests +async function handleGet() { + try { + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); + const items = await collection.find({}).toArray(); + return items; + } catch (error) { + console.error('Error fetching shopping list:', error); + throw error; + } +} + +// Function to handle POST requests +async function handlePost(req) { + const newItem = await req.json(); + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); // Ensure consistent collection name + await collection.insertOne(newItem); + return NextResponse.json({ success: true, data: newItem }, { status: 201 }); +} + +// Function to handle DELETE requests +async function handleDelete(req) { + const { id } = await req.json(); + const db = await connectToDatabase(); + const collection = db.collection('shopping_lists'); // Ensure consistent collection name + await collection.deleteOne({ _id: new ObjectId(id) }); + return NextResponse.json({ success: true }); +} + +export async function GET() { + try { + const items = await handleGet(); + return NextResponse.json( + { success: true, data: items }, // Ensure response structure matches client expectations + { status: 200 } + ); + } catch (error) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +export async function POST(req) { + try { + return await handlePost(req); + } catch (error) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +export async function DELETE(req) { + try { + return await handleDelete(req); + } catch (error) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} diff --git a/app/components/AllergensSection.js b/app/components/AllergensSection.js new file mode 100644 index 00000000..768a7f8e --- /dev/null +++ b/app/components/AllergensSection.js @@ -0,0 +1,89 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export default function AllergensSection({ recipeId }) { + const [allergens, setAllergens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchAllergens() { + try { + setIsLoading(true); + const response = await fetch(`/api/recipes/${recipeId}/allergens`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch allergens'); + } + + setAllergens(data.allergens); + setError(null); + } catch (err) { + console.error('Allergens fetch error:', err); + setError(err.message); + setAllergens([]); + } finally { + setIsLoading(false); + } + } + + if (recipeId) { + fetchAllergens(); + } + }, [recipeId]); + + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    + ); + } + + if (error) { + return ( +
    +
    + Unable to load allergens: {error} +
    +
    + ); + } + + if (!allergens || allergens.length === 0) { + return ( +
    +
    + No allergen information available +
    +
    + ); + } + + return ( +
    +

    + ⚠️ Potential Allergens +

    +
    + {allergens.map((allergen, index) => ( + + {allergen} + + ))} +
    +

    + This recipe may contain ingredients that are common allergens. + Please check the full ingredient list carefully. +

    +
    + ); +} diff --git a/app/components/BackButton.js b/app/components/BackButton.js deleted file mode 100644 index 299f002a..00000000 --- a/app/components/BackButton.js +++ /dev/null @@ -1,16 +0,0 @@ -"use client" - -export default function BackButton() { - - return ( - -) -} \ No newline at end of file diff --git a/app/components/CollapsibleSection.js b/app/components/CollapsibleSection.js index b32991a9..5b1ab836 100644 --- a/app/components/CollapsibleSection.js +++ b/app/components/CollapsibleSection.js @@ -8,25 +8,23 @@ function CollapsibleSection({ title, content, defaultOpen = true }) { const [isOpen, setIsOpen] = useState(defaultOpen); return ( -
    +
    {isOpen && ( -
    +
    {content}
    )} diff --git a/app/components/DownloadContext.js b/app/components/DownloadContext.js new file mode 100644 index 00000000..44b8a3cb --- /dev/null +++ b/app/components/DownloadContext.js @@ -0,0 +1,32 @@ +// // DownloadContext.js +// 'use client' +// import { createContext, useContext, useState } from 'react'; + +// const DownloadContext = createContext(); + +// export const DownloadProvider = ({ children }) => { +// const [downloadedRecipes, setDownloadedRecipes] = useState(() => { +// // Get downloaded recipes from localStorage on initial load +// if (typeof window !== 'undefined') { +// const savedRecipes = localStorage.getItem('downloadedRecipes'); +// return savedRecipes ? JSON.parse(savedRecipes) : {}; +// } +// return {}; +// }); + +// const downloadRecipe = (id, recipe) => { +// setDownloadedRecipes((prevRecipes) => { +// const newRecipes = { ...prevRecipes, [id]: recipe }; +// localStorage.setItem('downloadedRecipes', JSON.stringify(newRecipes)); +// return newRecipes; +// }); +// }; + +// return ( +// +// {children} +// +// ); +// }; + +// export const useDownload = () => useContext(DownloadContext); diff --git a/app/components/DynamicLink.js b/app/components/DynamicLink.js new file mode 100644 index 00000000..c9516197 --- /dev/null +++ b/app/components/DynamicLink.js @@ -0,0 +1,14 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function DynamicManifest() { + useEffect(() => { + const manifestLink = document.createElement('link'); + manifestLink.rel = 'manifest'; + manifestLink.href = '/api/manifest'; + document.head.appendChild(manifestLink); + }, []); + + return null; +} diff --git a/app/components/EditableRecipeDetails.js b/app/components/EditableRecipeDetails.js new file mode 100644 index 00000000..6f4a432b --- /dev/null +++ b/app/components/EditableRecipeDetails.js @@ -0,0 +1,215 @@ +"use client" + +import { useState, useEffect } from 'react'; +import { Button } from './ui/button'; +import { Pencil, X, Check, Loader2 } from 'lucide-react'; + +export default function EditableRecipeDetails({ id, initialDescription, lastEditedBy, lastEditedAt }) { + const [isEditing, setIsEditing] = useState(false); + const [description, setDescription] = useState(initialDescription); + const [message, setMessage] = useState(null); + const [editor, setEditor] = useState(lastEditedBy); + const [editDate, setEditDate] = useState(lastEditedAt); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const fetchRecipeData = async () => { + setIsLoading(true) + try { + const response = await fetch(`/api/recipes/${id}`); + const data = await response.json(); + setDescription(data.description); + setEditor(data.lastEditedBy); + setEditDate(data.lastEditedAt); + } catch (error) { + console.error('Error fetching recipe data:', error); + setMessage({ + type: 'error', + text: 'Something went wrong. Please try again later.' + }); + } finally { + setIsLoading(false); + } + }; + + fetchRecipeData(); + }, [id]); + + const handleEdit = async () => { + //validate the email is logged in correctly + const emailData = localStorage.getItem("loggedInUserEmail"); + + + if (!emailData) { + setMessage({ + type: "error", + text: "Please sign in to edit recipes.", + }); + return; + } + + setIsSaving(true); + try { + const userDetails = await fetch(`/api/auth/user/${[emailData]}/profile`); + if (userDetails.ok) { + const data = await userDetails.json(); + if (data._id) { + try { + const response = await fetch(`/api/recipes/${id}/update?email=${emailData}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ description }), + }); + + const data = await response.json(); + + if (!response.ok) { + if (response.status === 401) { + setMessage({ type: 'error', text: 'Please log in to edit recipes.' }); + return; + } + setMessage({ type: 'error', text: data.error || 'Something went wrong.' }); + return; + } + + setMessage({ type: 'success', text: 'Recipe description updated successfully!' }); + setEditor(data.lastEditedBy); + setEditDate(new Date(data.lastEditedAt).toLocaleString()); + setDescription(data.description); + setIsEditing(false); + + console.log(response) + } + catch (error) { + console.error('Error updating recipe:', error); + setMessage({ + type: 'error', + text: 'Something went wrong. Please try again later.' + }); + } + } else { + setMessage({ type: 'error', text: 'Please sign in to edit recipes.' }); + return; + } + } + else { + setMessage({ type: 'error', text: 'Please sign in to edit recipes.' }); + return; + } + } + catch (error) { + console.error('Error updating recipe:', error); + setMessage({ + type: 'error', + text: 'Something went wrong. Please try again later.' + }); + } finally { + setIsSaving(false); + } + } + + const handleCancel = () => { + setDescription(initialDescription); + setIsEditing(false); + setMessage(null); + }; + + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); + } + + return ( +
    +
    +

    + Description +

    + {!isEditing && ( + + )} +
    + + {message && ( +
    + {message.text} +
    + )} + + {isEditing ? ( +
    +