{"id":19977172,"url":"https://github.com/adrianhajdin/pricewise","last_synced_at":"2025-04-04T13:12:49.113Z","repository":{"id":197242320,"uuid":"698257758","full_name":"adrianhajdin/pricewise","owner":"adrianhajdin","description":"Dive into web scraping and build a Next.js 13 eCommerce price tracker within a single video that teaches you data scraping, cron jobs, sending emails, deployment, and more.","archived":false,"fork":false,"pushed_at":"2024-07-06T19:13:51.000Z","size":2911,"stargazers_count":542,"open_issues_count":12,"forks_count":169,"subscribers_count":8,"default_branch":"main","last_synced_at":"2025-03-28T12:08:58.644Z","etag":null,"topics":["scraping","webscraping"],"latest_commit_sha":null,"homepage":"https://pricewise-jsm.vercel.app","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/adrianhajdin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-09-29T14:06:29.000Z","updated_at":"2025-03-27T16:20:58.000Z","dependencies_parsed_at":"2023-12-27T18:25:06.752Z","dependency_job_id":"8adf0854-2e25-433a-a3b5-14a46ab9b03d","html_url":"https://github.com/adrianhajdin/pricewise","commit_stats":null,"previous_names":["adrianhajdin/pricewise"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fpricewise","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fpricewise/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fpricewise/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/adrianhajdin%2Fpricewise/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/adrianhajdin","download_url":"https://codeload.github.com/adrianhajdin/pricewise/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247182401,"owners_count":20897381,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["scraping","webscraping"],"created_at":"2024-11-13T03:27:12.700Z","updated_at":"2025-04-04T13:12:49.092Z","avatar_url":"https://github.com/adrianhajdin.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n  \u003cbr /\u003e\n    \u003ca href=\"https://youtu.be/lh9XVGv6BHs?si=BquPyhI_o2f8jHqV\" target=\"_blank\"\u003e\n      \u003cimg src=\"https://github.com/adrianhajdin/pricewise/assets/151519281/315377f2-0307-4ac2-87e0-55e053ca094b\" alt=\"Project Banner\"\u003e\n    \u003c/a\u003e\n  \u003cbr /\u003e\n\n  \u003cdiv\u003e\n   \u003cimg src=\"https://img.shields.io/badge/-Web_Scraping-black?style=for-the-badge\u0026logoColor=white\u0026color=FF0000\" alt=\"webscraping\" /\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-Next_JS-black?style=for-the-badge\u0026logoColor=white\u0026logo=nextdotjs\u0026color=000000\" alt=\"nextjs\" /\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-Tailwind_CSS-black?style=for-the-badge\u0026logoColor=white\u0026logo=tailwindcss\u0026color=06B6D4\" alt=\"tailwindcss\" /\u003e\n    \u003cimg src=\"https://img.shields.io/badge/-MongoDB-black?style=for-the-badge\u0026logoColor=white\u0026logo=mongodb\u0026color=47A248\" alt=\"mongodb\" /\u003e\n  \u003c/div\u003e\n\n  \u003ch3 align=\"center\"\u003eA Ecom Price Tracking Application\u003c/h3\u003e\n\n   \u003cdiv align=\"center\"\u003e\n     Build this project step by step with our detailed tutorial on \u003ca href=\"https://www.youtube.com/@javascriptmastery/videos\" target=\"_blank\"\u003e\u003cb\u003eJavaScript Mastery\u003c/b\u003e\u003c/a\u003e YouTube. Join the JSM family!\n    \u003c/div\u003e\n\u003c/div\u003e\n\n## 📋 \u003ca name=\"table\"\u003eTable of Contents\u003c/a\u003e\n\n1. 🤖 [Introduction](#introduction)\n2. ⚙️ [Tech Stack](#tech-stack)\n3. 🔋 [Features](#features)\n4. 🤸 [Quick Start](#quick-start)\n5. 🕸️ [Snippets](#snippets)\n6. 🔗 [Links](#links)\n7. 🚀 [More](#more)\n\n## 🚨 Tutorial\n\nThis repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, \u003ca href=\"https://www.youtube.com/@javascriptmastery/videos\" target=\"_blank\"\u003e\u003cb\u003eJavaScript Mastery\u003c/b\u003e\u003c/a\u003e. \n\nIf you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!\n\n\u003ca href=\"https://youtu.be/lh9XVGv6BHs?si=BquPyhI_o2f8jHqV\" target=\"_blank\"\u003e\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/1736fca5-a031-4854-8c09-bc110e3bc16d\" /\u003e\u003c/a\u003e\n\n## \u003ca name=\"introduction\"\u003e🤖 Introduction\u003c/a\u003e\n\nDeveloped using Next.js and Bright Data's webunlocker, this e-commerce product scraping site is designed to assist users in making informed decisions. It notifies users when a product drops in price and helps competitors by alerting them when the product is out of stock, all managed through cron jobs.\n\nIf you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out.\n\n\u003ca href=\"https://discord.com/invite/n6EdbFJ\" target=\"_blank\"\u003e\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/618f4872-1e10-42da-8213-1d69e486d02e\" /\u003e\u003c/a\u003e\n\n## \u003ca name=\"tech-stack\"\u003e⚙️ Tech Stack\u003c/a\u003e\n\n- Next.js\n- Bright Data\n- Cheerio\n- Nodemailer\n- MongoDB\n- Headless UI\n- Tailwind CSS\n\n## \u003ca name=\"features\"\u003e🔋 Features\u003c/a\u003e\n\n👉 **Header with Carousel**: Visually appealing header with a carousel showcasing key features and benefits\n\n👉 **Product Scraping**: A search bar allowing users to input Amazon product links for scraping.\n\n👉 **Scraped Projects**: Displays the details of products scraped so far, offering insights into tracked items.\n\n👉 **Scraped Product Details**: Showcase the product image, title, pricing, details, and other relevant information scraped from the original website\n\n👉 **Track Option**: Modal for users to provide email addresses and opt-in for tracking.\n\n👉 **Email Notifications**: Send emails product alert emails for various scenarios, e.g., back in stock alerts or lowest price notifications.\n\n👉 **Automated Cron Jobs**: Utilize cron jobs to automate periodic scraping, ensuring data is up-to-date.\n\nand many more, including code architecture and reusability \n\n## \u003ca name=\"quick-start\"\u003e🤸 Quick Start\u003c/a\u003e\n\nFollow these steps to set up the project locally on your machine.\n\n**Prerequisites**\n\nMake sure you have the following installed on your machine:\n\n- [Git](https://git-scm.com/)\n- [Node.js](https://nodejs.org/en)\n- [npm](https://www.npmjs.com/) (Node Package Manager)\n\n**Cloning the Repository**\n\n```bash\ngit clone https://github.com/adrianhajdin/pricewise.git\ncd pricewise\n```\n\n**Installation**\n\nInstall the project dependencies using npm:\n\n```bash\nnpm install\n```\n\n**Set Up Environment Variables**\n\nCreate a new file named `.env` in the root of your project and add the following content:\n\n```env\n#SCRAPER\nBRIGHT_DATA_USERNAME=\nBRIGHT_DATA_PASSWORD=\n\n#DB\nMONGODB_URI=\n\n#OUTLOOK\nEMAIL_USER=\nEMAIL_PASS=\n```\n\nReplace the placeholder values with your actual credentials. You can obtain these credentials by signing up on these specific websites from [BrightData](https://brightdata.com/), [MongoDB](https://www.mongodb.com/), and [Node Mailer](https://nodemailer.com/)\n\n**Running the Project**\n\n```bash\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) in your browser to view the project.\n\n## \u003ca name=\"snippets\"\u003e🕸️ Snippets\u003c/a\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003ecron.route.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nimport { NextResponse } from \"next/server\";\n\nimport { getLowestPrice, getHighestPrice, getAveragePrice, getEmailNotifType } from \"@/lib/utils\";\nimport { connectToDB } from \"@/lib/mongoose\";\nimport Product from \"@/lib/models/product.model\";\nimport { scrapeAmazonProduct } from \"@/lib/scraper\";\nimport { generateEmailBody, sendEmail } from \"@/lib/nodemailer\";\n\nexport const maxDuration = 300; // This function can run for a maximum of 300 seconds\nexport const dynamic = \"force-dynamic\";\nexport const revalidate = 0;\n\nexport async function GET(request: Request) {\n  try {\n    connectToDB();\n\n    const products = await Product.find({});\n\n    if (!products) throw new Error(\"No product fetched\");\n\n    // ======================== 1 SCRAPE LATEST PRODUCT DETAILS \u0026 UPDATE DB\n    const updatedProducts = await Promise.all(\n      products.map(async (currentProduct) =\u003e {\n        // Scrape product\n        const scrapedProduct = await scrapeAmazonProduct(currentProduct.url);\n\n        if (!scrapedProduct) return;\n\n        const updatedPriceHistory = [\n          ...currentProduct.priceHistory,\n          {\n            price: scrapedProduct.currentPrice,\n          },\n        ];\n\n        const product = {\n          ...scrapedProduct,\n          priceHistory: updatedPriceHistory,\n          lowestPrice: getLowestPrice(updatedPriceHistory),\n          highestPrice: getHighestPrice(updatedPriceHistory),\n          averagePrice: getAveragePrice(updatedPriceHistory),\n        };\n\n        // Update Products in DB\n        const updatedProduct = await Product.findOneAndUpdate(\n          {\n            url: product.url,\n          },\n          product\n        );\n\n        // ======================== 2 CHECK EACH PRODUCT'S STATUS \u0026 SEND EMAIL ACCORDINGLY\n        const emailNotifType = getEmailNotifType(\n          scrapedProduct,\n          currentProduct\n        );\n\n        if (emailNotifType \u0026\u0026 updatedProduct.users.length \u003e 0) {\n          const productInfo = {\n            title: updatedProduct.title,\n            url: updatedProduct.url,\n          };\n          // Construct emailContent\n          const emailContent = await generateEmailBody(productInfo, emailNotifType);\n          // Get array of user emails\n          const userEmails = updatedProduct.users.map((user: any) =\u003e user.email);\n          // Send email notification\n          await sendEmail(emailContent, userEmails);\n        }\n\n        return updatedProduct;\n      })\n    );\n\n    return NextResponse.json({\n      message: \"Ok\",\n      data: updatedProducts,\n    });\n  } catch (error: any) {\n    throw new Error(`Failed to get all products: ${error.message}`);\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003egenerateEmailBody.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nexport async function generateEmailBody(\n  product: EmailProductInfo,\n  type: NotificationType\n  ) {\n  const THRESHOLD_PERCENTAGE = 40;\n  // Shorten the product title\n  const shortenedTitle =\n    product.title.length \u003e 20\n      ? `${product.title.substring(0, 20)}...`\n      : product.title;\n\n  let subject = \"\";\n  let body = \"\";\n\n  switch (type) {\n    case Notification.WELCOME:\n      subject = `Welcome to Price Tracking for ${shortenedTitle}`;\n      body = `\n        \u003cdiv\u003e\n          \u003ch2\u003eWelcome to PriceWise 🚀\u003c/h2\u003e\n          \u003cp\u003eYou are now tracking ${product.title}.\u003c/p\u003e\n          \u003cp\u003eHere's an example of how you'll receive updates:\u003c/p\u003e\n          \u003cdiv style=\"border: 1px solid #ccc; padding: 10px; background-color: #f8f8f8;\"\u003e\n            \u003ch3\u003e${product.title} is back in stock!\u003c/h3\u003e\n            \u003cp\u003eWe're excited to let you know that ${product.title} is now back in stock.\u003c/p\u003e\n            \u003cp\u003eDon't miss out - \u003ca href=\"${product.url}\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ebuy it now\u003c/a\u003e!\u003c/p\u003e\n            \u003cimg src=\"https://i.ibb.co/pwFBRMC/Screenshot-2023-09-26-at-1-47-50-AM.png\" alt=\"Product Image\" style=\"max-width: 100%;\" /\u003e\n          \u003c/div\u003e\n          \u003cp\u003eStay tuned for more updates on ${product.title} and other products you're tracking.\u003c/p\u003e\n        \u003c/div\u003e\n      `;\n      break;\n\n    case Notification.CHANGE_OF_STOCK:\n      subject = `${shortenedTitle} is now back in stock!`;\n      body = `\n        \u003cdiv\u003e\n          \u003ch4\u003eHey, ${product.title} is now restocked! Grab yours before they run out again!\u003c/h4\u003e\n          \u003cp\u003eSee the product \u003ca href=\"${product.url}\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehere\u003c/a\u003e.\u003c/p\u003e\n        \u003c/div\u003e\n      `;\n      break;\n\n    case Notification.LOWEST_PRICE:\n      subject = `Lowest Price Alert for ${shortenedTitle}`;\n      body = `\n        \u003cdiv\u003e\n          \u003ch4\u003eHey, ${product.title} has reached its lowest price ever!!\u003c/h4\u003e\n          \u003cp\u003eGrab the product \u003ca href=\"${product.url}\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehere\u003c/a\u003e now.\u003c/p\u003e\n        \u003c/div\u003e\n      `;\n      break;\n\n    case Notification.THRESHOLD_MET:\n      subject = `Discount Alert for ${shortenedTitle}`;\n      body = `\n        \u003cdiv\u003e\n          \u003ch4\u003eHey, ${product.title} is now available at a discount more than ${THRESHOLD_PERCENTAGE}%!\u003c/h4\u003e\n          \u003cp\u003eGrab it right away from \u003ca href=\"${product.url}\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehere\u003c/a\u003e.\u003c/p\u003e\n        \u003c/div\u003e\n      `;\n      break;\n\n    default:\n      throw new Error(\"Invalid notification type.\");\n  }\n\n  return { subject, body };\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eglobals.css\u003c/code\u003e\u003c/summary\u003e\n\n```css\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n  scroll-behavior: smooth;\n}\n\n@layer base {\n  body {\n    @apply font-inter;\n  }\n}\n\n@layer utilities {\n  .btn {\n    @apply py-4 px-4 bg-secondary hover:bg-opacity-70 rounded-[30px] text-white text-lg font-semibold;\n  }\n\n  .head-text {\n    @apply mt-4 text-6xl leading-[72px] font-bold tracking-[-1.2px] text-gray-900;\n  }\n\n  .section-text {\n    @apply text-secondary text-[32px] font-semibold;\n  }\n\n  .small-text {\n    @apply flex gap-2 text-sm font-medium text-primary;\n  }\n\n  .paragraph-text {\n    @apply text-xl leading-[30px] text-gray-600;\n  }\n\n  .hero-carousel {\n    @apply relative sm:px-10 py-5 sm:pt-20 pb-5 max-w-[560px] h-[700px] w-full bg-[#F2F4F7] rounded-[30px] sm:mx-auto;\n  }\n\n  .carousel {\n    @apply flex flex-col-reverse h-[700px];\n  }\n\n  .carousel .control-dots {\n    @apply static !important;\n  }\n\n  .carousel .control-dots .dot {\n    @apply w-[10px] h-[10px] bg-[#D9D9D9] rounded-full bottom-0 !important;\n  }\n\n  .carousel .control-dots .dot.selected {\n    @apply bg-[#475467] !important;\n  }\n\n  .trending-section {\n    @apply flex flex-col gap-10 px-6 md:px-20 py-24;\n  }\n\n  /* PRODUCT DETAILS PAGE STYLES */\n  .product-container {\n    @apply flex flex-col gap-16 flex-wrap px-6 md:px-20 py-24;\n  }\n\n  .product-image {\n    @apply flex-grow xl:max-w-[50%] max-w-full py-16 border border-[#CDDBFF] rounded-[17px];\n  }\n\n  .product-info {\n    @apply flex items-center flex-wrap gap-10 py-6 border-y border-y-[#E4E4E4];\n  }\n\n  .product-hearts {\n    @apply flex items-center gap-2 px-3 py-2 bg-[#FFF0F0] rounded-10;\n  }\n\n  .product-stars {\n    @apply flex items-center gap-2 px-3 py-2 bg-[#FBF3EA] rounded-[27px];\n  }\n\n  .product-reviews {\n    @apply flex items-center gap-2 px-3 py-2 bg-white-200 rounded-[27px];\n  }\n\n  /* MODAL */\n  .dialog-container {\n    @apply fixed inset-0 z-10 overflow-y-auto bg-black bg-opacity-60;\n  }\n\n  .dialog-content {\n    @apply p-6  bg-white inline-block w-full max-w-md my-8 overflow-hidden text-left align-middle transition-all transform  shadow-xl rounded-2xl;\n  }\n\n  .dialog-head_text {\n    @apply text-secondary text-lg leading-[24px] font-semibold mt-4;\n  }\n\n  .dialog-input_container {\n    @apply px-5 py-3 mt-3 flex items-center gap-2 border border-gray-300 rounded-[27px];\n  }\n\n  .dialog-input {\n    @apply flex-1 pl-1 border-none text-gray-500 text-base focus:outline-none border border-gray-300 rounded-[27px] shadow-xs;\n  }\n\n  .dialog-btn {\n    @apply px-5 py-3 text-white text-base font-semibold border border-secondary bg-secondary rounded-lg mt-8;\n  }\n\n  /* NAVBAR */\n  .nav {\n    @apply flex justify-between items-center px-6 md:px-20 py-4;\n  }\n\n  .nav-logo {\n    @apply font-spaceGrotesk text-[21px] text-secondary font-bold;\n  }\n\n  /* PRICE INFO */\n  .price-info_card {\n    @apply flex-1 min-w-[200px] flex flex-col gap-2 border-l-[3px] rounded-10 bg-white-100 px-5 py-4;\n  }\n\n  /* PRODUCT CARD */\n  .product-card {\n    @apply sm:w-[292px] sm:max-w-[292px] w-full flex-1 flex flex-col gap-4 rounded-md;\n  }\n\n  .product-card_img-container {\n    @apply flex-1 relative flex flex-col gap-5 p-4 rounded-md;\n  }\n\n  .product-card_img {\n    @apply max-h-[250px] object-contain w-full h-full bg-transparent;\n  }\n\n  .product-title {\n    @apply text-secondary text-xl leading-6 font-semibold truncate;\n  }\n\n  /* SEARCHBAR INPUT */\n  .searchbar-input {\n    @apply flex-1 min-w-[200px] w-full p-3 border border-gray-300 rounded-lg shadow-xs text-base text-gray-500 focus:outline-none;\n  }\n\n  .searchbar-btn {\n    @apply bg-gray-900 border border-gray-900 rounded-lg shadow-xs px-5 py-3 text-white text-base font-semibold hover:opacity-90 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40;\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eindex.scraper.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\n\"use server\"\n\nimport axios from 'axios';\nimport * as cheerio from 'cheerio';\nimport { extractCurrency, extractDescription, extractPrice } from '../utils';\n\nexport async function scrapeAmazonProduct(url: string) {\n  if(!url) return;\n\n  // BrightData proxy configuration\n  const username = String(process.env.BRIGHT_DATA_USERNAME);\n  const password = String(process.env.BRIGHT_DATA_PASSWORD);\n  const port = 22225;\n  const session_id = (1000000 * Math.random()) | 0;\n\n  const options = {\n    auth: {\n      username: `${username}-session-${session_id}`,\n      password,\n    },\n    host: 'brd.superproxy.io',\n    port,\n    rejectUnauthorized: false,\n  }\n\n  try {\n    // Fetch the product page\n    const response = await axios.get(url, options);\n    const $ = cheerio.load(response.data);\n\n    // Extract the product title\n    const title = $('#productTitle').text().trim();\n    const currentPrice = extractPrice(\n      $('.priceToPay span.a-price-whole'),\n      $('.a.size.base.a-color-price'),\n      $('.a-button-selected .a-color-base'),\n    );\n\n    const originalPrice = extractPrice(\n      $('#priceblock_ourprice'),\n      $('.a-price.a-text-price span.a-offscreen'),\n      $('#listPrice'),\n      $('#priceblock_dealprice'),\n      $('.a-size-base.a-color-price')\n    );\n\n    const outOfStock = $('#availability span').text().trim().toLowerCase() === 'currently unavailable';\n\n    const images = \n      $('#imgBlkFront').attr('data-a-dynamic-image') || \n      $('#landingImage').attr('data-a-dynamic-image') ||\n      '{}'\n\n    const imageUrls = Object.keys(JSON.parse(images));\n\n    const currency = extractCurrency($('.a-price-symbol'))\n    const discountRate = $('.savingsPercentage').text().replace(/[-%]/g, \"\");\n\n    const description = extractDescription($)\n\n    // Construct data object with scraped information\n    const data = {\n      url,\n      currency: currency || '$',\n      image: imageUrls[0],\n      title,\n      currentPrice: Number(currentPrice) || Number(originalPrice),\n      originalPrice: Number(originalPrice) || Number(currentPrice),\n      priceHistory: [],\n      discountRate: Number(discountRate),\n      category: 'category',\n      reviewsCount:100,\n      stars: 4.5,\n      isOutOfStock: outOfStock,\n      description,\n      lowestPrice: Number(currentPrice) || Number(originalPrice),\n      highestPrice: Number(originalPrice) || Number(currentPrice),\n      averagePrice: Number(currentPrice) || Number(originalPrice),\n    }\n\n    return data;\n  } catch (error: any) {\n    console.log(error);\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003enext.config.js\u003c/code\u003e\u003c/summary\u003e\n\n```javascript\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  experimental: {\n    serverActions: true,\n    serverComponentsExternalPackages: ['mongoose']\n  },\n  images: {\n    domains: ['m.media-amazon.com']\n  }\n}\n\nmodule.exports = nextConfig\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003etailwind.config.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\n    \"./pages/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./components/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./app/**/*.{js,ts,jsx,tsx,mdx}\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        primary: {\n          DEFAULT: \"#E43030\",\n          \"orange\": \"#D48D3B\",\n          \"green\": \"#3E9242\"\n        },\n        secondary: \"#282828\",\n        \"gray-200\": \"#EAECF0\",\n        \"gray-300\": \"D0D5DD\",\n        \"gray-500\": \"#667085\",\n        \"gray-600\": \"#475467\",\n        \"gray-700\": \"#344054\",\n        \"gray-900\": \"#101828\",\n        \"white-100\": \"#F4F4F4\",\n        \"white-200\": \"#EDF0F8\",\n        \"black-100\": \"#3D4258\",\n        \"neutral-black\": \"#23263B\",\n      },\n      boxShadow: {\n        xs: \"0px 1px 2px 0px rgba(16, 24, 40, 0.05)\",\n      },\n      maxWidth: {\n        \"10xl\": '1440px'\n      },\n      fontFamily: {\n        inter: ['Inter', 'sans-serif'],\n        spaceGrotesk: ['Space Grotesk', 'sans-serif'],\n      },\n      borderRadius: {\n        10: \"10px\"\n      }\n    },\n  },\n  plugins: [],\n};\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003etypes.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nexport type PriceHistoryItem = {\n  price: number;\n};\n\nexport type User = {\n  email: string;\n};\n\nexport type Product = {\n  _id?: string;\n  url: string;\n  currency: string;\n  image: string;\n  title: string;\n  currentPrice: number;\n  originalPrice: number;\n  priceHistory: PriceHistoryItem[] | [];\n  highestPrice: number;\n  lowestPrice: number;\n  averagePrice: number;\n  discountRate: number;\n  description: string;\n  category: string;\n  reviewsCount: number;\n  stars: number;\n  isOutOfStock: Boolean;\n  users?: User[];\n};\n\nexport type NotificationType =\n  | \"WELCOME\"\n  | \"CHANGE_OF_STOCK\"\n  | \"LOWEST_PRICE\"\n  | \"THRESHOLD_MET\";\n\nexport type EmailContent = {\n  subject: string;\n  body: string;\n};\n\nexport type EmailProductInfo = {\n  title: string;\n  url: string;\n};\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003ccode\u003eutils.ts\u003c/code\u003e\u003c/summary\u003e\n\n```typescript\nimport { PriceHistoryItem, Product } from \"@/types\";\n\nconst Notification = {\n  WELCOME: 'WELCOME',\n  CHANGE_OF_STOCK: 'CHANGE_OF_STOCK',\n  LOWEST_PRICE: 'LOWEST_PRICE',\n  THRESHOLD_MET: 'THRESHOLD_MET',\n}\n\nconst THRESHOLD_PERCENTAGE = 40;\n\n// Extracts and returns the price from a list of possible elements.\nexport function extractPrice(...elements: any) {\n  for (const element of elements) {\n    const priceText = element.text().trim();\n\n    if(priceText) {\n      const cleanPrice = priceText.replace(/[^\\d.]/g, '');\n\n      let firstPrice; \n\n      if (cleanPrice) {\n        firstPrice = cleanPrice.match(/\\d+\\.\\d{2}/)?.[0];\n      } \n\n      return firstPrice || cleanPrice;\n    }\n  }\n\n  return '';\n}\n\n// Extracts and returns the currency symbol from an element.\nexport function extractCurrency(element: any) {\n  const currencyText = element.text().trim().slice(0, 1);\n  return currencyText ? currencyText : \"\";\n}\n\n// Extracts description from two possible elements from amazon\nexport function extractDescription($: any) {\n  // these are possible elements holding description of the product\n  const selectors = [\n    \".a-unordered-list .a-list-item\",\n    \".a-expander-content p\",\n    // Add more selectors here if needed\n  ];\n\n  for (const selector of selectors) {\n    const elements = $(selector);\n    if (elements.length \u003e 0) {\n      const textContent = elements\n        .map((_: any, element: any) =\u003e $(element).text().trim())\n        .get()\n        .join(\"\\n\");\n      return textContent;\n    }\n  }\n\n  // If no matching elements were found, return an empty string\n  return \"\";\n}\n\nexport function getHighestPrice(priceList: PriceHistoryItem[]) {\n  let highestPrice = priceList[0];\n\n  for (let i = 0; i \u003c priceList.length; i++) {\n    if (priceList[i].price \u003e highestPrice.price) {\n      highestPrice = priceList[i];\n    }\n  }\n\n  return highestPrice.price;\n}\n\nexport function getLowestPrice(priceList: PriceHistoryItem[]) {\n  let lowestPrice = priceList[0];\n\n  for (let i = 0; i \u003c priceList.length; i++) {\n    if (priceList[i].price \u003c lowestPrice.price) {\n      lowestPrice = priceList[i];\n    }\n  }\n\n  return lowestPrice.price;\n}\n\nexport function getAveragePrice(priceList: PriceHistoryItem[]) {\n  const sumOfPrices = priceList.reduce((acc, curr) =\u003e acc + curr.price, 0);\n  const averagePrice = sumOfPrices / priceList.length || 0;\n\n  return averagePrice;\n}\n\nexport const getEmailNotifType = (\n  scrapedProduct: Product,\n  currentProduct: Product\n) =\u003e {\n  const lowestPrice = getLowestPrice(currentProduct.priceHistory);\n\n  if (scrapedProduct.currentPrice \u003c lowestPrice) {\n    return Notification.LOWEST_PRICE as keyof typeof Notification;\n  }\n  if (!scrapedProduct.isOutOfStock \u0026\u0026 currentProduct.isOutOfStock) {\n    return Notification.CHANGE_OF_STOCK as keyof typeof Notification;\n  }\n  if (scrapedProduct.discountRate \u003e= THRESHOLD_PERCENTAGE) {\n    return Notification.THRESHOLD_MET as keyof typeof Notification;\n  }\n\n  return null;\n};\n\nexport const formatNumber = (num: number = 0) =\u003e {\n  return num.toLocaleString(undefined, {\n    minimumFractionDigits: 0,\n    maximumFractionDigits: 0,\n  });\n};\n```\n\n\u003c/details\u003e\n\n## \u003ca name=\"links\"\u003e🔗 Links\u003c/a\u003e\n\nAssets used in the project are [here](https://drive.google.com/file/d/1v6h993BgYX6axBoIXFbZ9HQAgqbR4PSH/view?usp=sharing)\n\n## \u003ca name=\"more\"\u003e🚀 More\u003c/a\u003e\n\n**Advance your skills with Next.js 14 Pro Course**\n\nEnjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!\n\n\u003ca href=\"https://jsmastery.pro/next14\" target=\"_blank\"\u003e\n\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/557837ce-f612-4530-ab24-189e75133c71\" alt=\"Project Banner\"\u003e\n\u003c/a\u003e\n\n\u003cbr /\u003e\n\u003cbr /\u003e\n\n**Accelerate your professional journey with the Expert Training program**\n\nAnd if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!\n\n\u003ca href=\"https://www.jsmastery.pro/masterclass\" target=\"_blank\"\u003e\n\u003cimg src=\"https://github.com/sujatagunale/EasyRead/assets/151519281/fed352ad-f27b-400d-9b8f-c7fe628acb84\" alt=\"Project Banner\"\u003e\n\u003c/a\u003e\n\n#\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianhajdin%2Fpricewise","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fadrianhajdin%2Fpricewise","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fadrianhajdin%2Fpricewise/lists"}