├── app ├── favicon.ico ├── layout.tsx ├── page.tsx ├── api │ └── cron │ │ └── route.ts ├── globals.css └── products │ └── [id] │ └── page.tsx ├── postcss.config.js ├── public ├── assets │ └── icons │ │ ├── x-close.svg │ │ ├── chevron-down.svg │ │ ├── arrow-right.svg │ │ ├── bookmark.svg │ │ ├── hand-drawn-arrow.svg │ │ ├── red-heart.svg │ │ ├── arrow-down.svg │ │ ├── arrow-up.svg │ │ ├── check.svg │ │ ├── square.svg │ │ ├── search.svg │ │ ├── mail.svg │ │ ├── black-heart.svg │ │ ├── chart.svg │ │ ├── user.svg │ │ ├── bag.svg │ │ ├── star.svg │ │ ├── comment.svg │ │ ├── price-tag.svg │ │ ├── share.svg │ │ ├── frame.svg │ │ └── logo.svg ├── vercel.svg └── next.svg ├── next.config.js ├── .gitignore ├── lib ├── mongoose.ts ├── models │ └── product.model.ts ├── scraper │ └── index.ts ├── actions │ └── index.ts ├── utils.ts └── nodemailer │ └── index.ts ├── components ├── PriceInfoCard.tsx ├── ProductCard.tsx ├── Navbar.tsx ├── HeroCarousel.tsx ├── Searchbar.tsx └── Modal.tsx ├── tsconfig.json ├── package.json ├── types └── index.ts ├── tailwind.config.ts └── README.md /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emredkyc/price_tracker/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/icons/x-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | serverComponentsExternalPackages: ["mongoose"], 6 | }, 7 | images: { 8 | domains: ["m.media-amazon.com"], 9 | }, 10 | }; 11 | 12 | module.exports = nextConfig; -------------------------------------------------------------------------------- /public/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/hand-drawn-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/red-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /public/assets/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/mongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | let isConnected = false; // Variable to track the connection status 4 | 5 | export const connectToDB = async () => { 6 | mongoose.set("strictQuery", true); 7 | 8 | if (!process.env.MONGODB_URI) 9 | return console.log("MONGODB_URI is not defined"); 10 | 11 | if (isConnected) return console.log("=> using existing database connection"); 12 | 13 | try { 14 | await mongoose.connect(process.env.MONGODB_URI); 15 | 16 | isConnected = true; 17 | 18 | console.log("MongoDB Connected"); 19 | } catch (error) { 20 | console.log(error); 21 | } 22 | }; -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/PriceInfoCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | interface Props { 4 | title: string; 5 | iconSrc: string; 6 | value: string; 7 | } 8 | 9 | const PriceInfoCard = ({ title, iconSrc, value }: Props) => { 10 | return ( 11 |
12 |

{title}

13 | 14 |
15 | {title} 16 | 17 |

{value}

18 |
19 |
20 | ); 21 | }; 22 | 23 | export default PriceInfoCard -------------------------------------------------------------------------------- /public/assets/icons/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /public/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "price_tracker", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.18", 13 | "@types/nodemailer": "^6.4.14", 14 | "axios": "^1.6.5", 15 | "cheerio": "^1.0.0-rc.12", 16 | "mongoose": "^8.2.0", 17 | "next": "14.0.4", 18 | "nodemailer": "^6.9.11", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "react-responsive-carousel": "^3.2.23" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20", 25 | "@types/react": "^18", 26 | "@types/react-dom": "^18", 27 | "autoprefixer": "^10.0.1", 28 | "postcss": "^8", 29 | "tailwindcss": "^3.3.0", 30 | "typescript": "^5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/assets/icons/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | import type { Metadata } from 'next' 3 | import { Inter, Space_Grotesk } from 'next/font/google' 4 | 5 | import Navbar from '@/components/Navbar' 6 | 7 | 8 | const inter = Inter({ subsets: ['latin'] }) 9 | const spaceGrotesk = Space_Grotesk({ 10 | subsets: ['latin'], weight: ['300', '400', '500', '600'] 11 | }) 12 | 13 | export const metadata: Metadata = { 14 | title: 'Price Tracker', 15 | description: 'Track product prices effortlessly and save money on your online shopping.', 16 | } 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode 22 | }) { 23 | return ( 24 | 25 | 26 |
27 | 28 | {children} 29 |
30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type PriceHistoryItem = { 2 | price: number; 3 | }; 4 | 5 | export type User = { 6 | email: string; 7 | }; 8 | 9 | export type Product = { 10 | _id?: string; 11 | url: string; 12 | currency: string; 13 | image: string; 14 | title: string; 15 | currentPrice: number; 16 | originalPrice: number; 17 | priceHistory: PriceHistoryItem[] | []; 18 | highestPrice: number; 19 | lowestPrice: number; 20 | averagePrice: number; 21 | discountRate: number; 22 | description: string; 23 | category: string; 24 | reviewsCount: number; 25 | stars: number; 26 | isOutOfStock: Boolean; 27 | users?: User[]; 28 | }; 29 | 30 | export type NotificationType = 31 | | "WELCOME" 32 | | "CHANGE_OF_STOCK" 33 | | "LOWEST_PRICE" 34 | | "THRESHOLD_MET"; 35 | 36 | export type EmailContent = { 37 | subject: string; 38 | body: string; 39 | }; 40 | 41 | export type EmailProductInfo = { 42 | title: string; 43 | url: string; 44 | }; -------------------------------------------------------------------------------- /public/assets/icons/black-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/chart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /lib/models/product.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const productSchema = new mongoose.Schema( 4 | { 5 | url: { type: String, required: true, unique: true }, 6 | currency: { type: String, required: true }, 7 | image: { type: String, required: true }, 8 | title: { type: String, required: true }, 9 | currentPrice: { type: Number, required: true }, 10 | originalPrice: { type: Number, required: true }, 11 | priceHistory: [ 12 | { 13 | price: { type: Number, required: true }, 14 | date: { type: Date, default: Date.now }, 15 | }, 16 | ], 17 | lowestPrice: { type: Number }, 18 | highestPrice: { type: Number }, 19 | averagePrice: { type: Number }, 20 | discountRate: { type: Number }, 21 | description: { type: String }, 22 | category: { type: String }, 23 | reviewsCount: { type: Number }, 24 | isOutOfStock: { type: Boolean, default: false }, 25 | users: [{ email: { type: String, required: true } }], 26 | default: [], 27 | }, 28 | { timestamps: true } 29 | ); 30 | 31 | const Product = 32 | mongoose.models.Product || mongoose.model("Product", productSchema); 33 | 34 | export default Product -------------------------------------------------------------------------------- /public/assets/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/ProductCard.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/types"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | interface Props { 7 | product: Product; 8 | } 9 | 10 | const ProductCard = ({ product }: Props) => { 11 | return ( 12 | 13 |
14 | {product.title} 21 |
22 | 23 |
24 |

{product.title}

25 | 26 |
27 |

28 | {product.category} 29 |

30 | 31 |

32 | {product?.currency} 33 | {product?.currentPrice} 34 |

35 |
36 |
37 | 38 | ); 39 | }; 40 | 41 | export default ProductCard -------------------------------------------------------------------------------- /public/assets/icons/bag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | primary: { 12 | DEFAULT: "#E43030", 13 | orange: "#D48D3B", 14 | green: "#3E9242", 15 | }, 16 | secondary: "#282828", 17 | "gray-200": "#EAECF0", 18 | "gray-300": "D0D5DD", 19 | "gray-500": "#667085", 20 | "gray-600": "#475467", 21 | "gray-700": "#344054", 22 | "gray-900": "#101828", 23 | "white-100": "#F4F4F4", 24 | "white-200": "#EDF0F8", 25 | "black-100": "#3D4258", 26 | "neutral-black": "#23263B", 27 | }, 28 | boxShadow: { 29 | xs: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", 30 | }, 31 | maxWidth: { 32 | "10xl": "1440px", 33 | }, 34 | fontFamily: { 35 | inter: ["Inter", "sans-serif"], 36 | spaceGrotesk: ["Space Grotesk", "sans-serif"], 37 | }, 38 | borderRadius: { 39 | 10: "10px", 40 | }, 41 | }, 42 | }, 43 | plugins: [], 44 | }; -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const navIcons = [ 5 | { src: "/assets/icons/search.svg", alt: "search" }, 6 | { src: "/assets/icons/black-heart.svg", alt: "heart" }, 7 | { src: "/assets/icons/user.svg", alt: "user" }, 8 | ]; 9 | 10 | const Navbar = () => { 11 | return ( 12 |
13 | 40 |
41 | ); 42 | }; 43 | 44 | export default Navbar -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/HeroCarousel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import "react-responsive-carousel/lib/styles/carousel.min.css"; 4 | import { Carousel } from "react-responsive-carousel"; 5 | import Image from "next/image"; 6 | 7 | const heroImage = [ 8 | { imgUrl: "/assets/images/hero-1.svg", alt: "smartwatch" }, 9 | { imgUrl: "/assets/images/hero-2.svg", alt: "bag" }, 10 | { imgUrl: "/assets/images/hero-3.svg", alt: "lamp" }, 11 | { imgUrl: "/assets/images/hero-4.svg", alt: "air fryer" }, 12 | { imgUrl: "/assets/images/hero-5.svg", alt: "chair" }, 13 | ]; 14 | 15 | const HeroCarousel = () => { 16 | return ( 17 |
18 | 26 | {heroImage.map((image) => ( 27 | {image.alt} 35 | ))} 36 | 37 | 38 | arrow 45 |
46 | ) 47 | } 48 | 49 | export default HeroCarousel -------------------------------------------------------------------------------- /public/assets/icons/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/comment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import HeroCarousel from "@/components/HeroCarousel"; 2 | import ProductCard from "@/components/ProductCard"; 3 | import Searchbar from "@/components/Searchbar"; 4 | import { getAllProducts } from "@/lib/actions"; 5 | import Image from "next/image"; 6 | 7 | const Home = async () => { 8 | const allProducts = await getAllProducts(); 9 | 10 | return ( 11 | <> 12 |
13 |
14 |
15 |

16 | Smart Shopping Starts Here: 17 | arrow-right 23 |

24 | 25 |

26 | Unleash the Power of 27 | PriceTracker 28 |

29 | 30 |

31 | Powerful, self-serve product and growth analytics to help you 32 | convert, engage, and retain more. 33 |

34 | 35 | 36 |
37 | 38 | 39 |
40 |
41 | 42 |
43 |

Trending

44 |
45 | {allProducts?.map((product) => ( 46 | 47 | ))} 48 |
49 |
50 | 51 | ); 52 | }; 53 | 54 | export default Home -------------------------------------------------------------------------------- /components/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { scrapeAndStoreProduct } from "@/lib/actions"; 4 | import { FormEvent, useState } from "react"; 5 | 6 | const isValidAmazonProductURL = (url: string) => { 7 | try { 8 | const parsedURL = new URL(url); 9 | const hostname = parsedURL.hostname; 10 | 11 | if ( 12 | hostname.includes("amazon.com") || 13 | hostname.includes("amazon.") || 14 | hostname.endsWith("amazon") 15 | ) { 16 | return true; 17 | } 18 | } catch (error) { 19 | return false; 20 | } 21 | 22 | return false; 23 | }; 24 | 25 | const Searchbar = () => { 26 | const [searchPrompt, setSearchPrompt] = useState(""); 27 | const [isLoading, setIsLoading] = useState(false); 28 | 29 | const handleSubmit = async (event: FormEvent) => { 30 | event.preventDefault(); 31 | 32 | const isValidLink = isValidAmazonProductURL(searchPrompt); 33 | 34 | if (!isValidLink) return alert("Please provide a valid Amazon link"); 35 | 36 | try { 37 | setIsLoading(true); 38 | 39 | // Scrape the product page 40 | const product = await scrapeAndStoreProduct(searchPrompt); 41 | } catch (error) { 42 | console.log(error); 43 | } finally { 44 | setIsLoading(false); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 | setSearchPrompt(e.target.value)} 54 | placeholder="Enter product link" 55 | className="searchbar-input" 56 | /> 57 | 58 | 64 |
65 | ); 66 | }; 67 | 68 | export default Searchbar -------------------------------------------------------------------------------- /public/assets/icons/price-tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/assets/icons/share.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/frame.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/assets/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/scraper/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import axios from "axios"; 4 | import * as cheerio from "cheerio"; 5 | import { extractCurrency, extractDescription, extractPrice } from "../utils"; 6 | 7 | export async function scrapeAmazonProduct(url: string) { 8 | if (!url) return; 9 | 10 | // BrightData proxy configuration 11 | const username = String(process.env.BRIGHT_DATA_USERNAME); 12 | const password = String(process.env.BRIGHT_DATA_PASSWORD); 13 | const port = 22225; 14 | const session_id = (1000000 * Math.random()) | 0; 15 | 16 | const options = { 17 | auth: { 18 | username: `${username}-session-${session_id}`, 19 | password, 20 | }, 21 | host: "brd.superproxy.io", 22 | port, 23 | rejectUnauthorized: false, 24 | }; 25 | 26 | try { 27 | // Fetch the product page 28 | const response = await axios.get(url, options); 29 | const $ = cheerio.load(response.data); 30 | 31 | // Extract the product title 32 | const title = $("#productTitle").text().trim(); 33 | const currentPrice = extractPrice( 34 | $(".priceToPay span.a-price-whole"), 35 | $(".a.size.base.a-color-price"), 36 | $(".a-button-selected .a-color-base") 37 | ); 38 | 39 | const originalPrice = extractPrice( 40 | $("#priceblock_ourprice"), 41 | $(".a-price.a-text-price span.a-offscreen"), 42 | $("#listPrice"), 43 | $("#priceblock_dealprice"), 44 | $(".a-size-base.a-color-price") 45 | ); 46 | 47 | const outOfStock = 48 | $("#availability span").text().trim().toLowerCase() === 49 | "currently unavailable"; 50 | 51 | const images = 52 | $("#imgBlkFront").attr("data-a-dynamic-image") || 53 | $("#landingImage").attr("data-a-dynamic-image") || 54 | "{}"; 55 | 56 | const imageUrls = Object.keys(JSON.parse(images)); 57 | 58 | const currency = extractCurrency($(".a-price-symbol")); 59 | const discountRate = $(".savingsPercentage").text().replace(/[-%]/g, ""); 60 | 61 | const description = extractDescription($); 62 | 63 | // Construct data object with scraped information 64 | const data = { 65 | url, 66 | currency: currency || "$", 67 | image: imageUrls[0], 68 | title, 69 | currentPrice: Number(currentPrice) || Number(originalPrice), 70 | originalPrice: Number(originalPrice) || Number(currentPrice), 71 | priceHistory: [], 72 | discountRate: Number(discountRate), 73 | category: "category", 74 | reviewsCount: 100, 75 | stars: 4.5, 76 | isOutOfStock: outOfStock, 77 | description, 78 | lowestPrice: Number(currentPrice) || Number(originalPrice), 79 | highestPrice: Number(originalPrice) || Number(currentPrice), 80 | averagePrice: Number(currentPrice) || Number(originalPrice), 81 | }; 82 | 83 | return data; 84 | } catch (error: any) { 85 | console.log(error); 86 | } 87 | } -------------------------------------------------------------------------------- /app/api/cron/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { getLowestPrice, getHighestPrice, getAveragePrice, getEmailNotifType } from "@/lib/utils"; 3 | import { connectToDB } from "@/lib/mongoose"; 4 | import Product from "@/lib/models/product.model"; 5 | import { scrapeAmazonProduct } from "@/lib/scraper"; 6 | import { generateEmailBody, sendEmail } from "@/lib/nodemailer"; 7 | 8 | export const maxDuration = 5; // This function can run for a maximum of 5 minutes 9 | export const dynamic = "force-dynamic"; 10 | export const revalidate = 0; 11 | 12 | export async function GET(request: Request) { 13 | try { 14 | connectToDB(); 15 | 16 | const products = await Product.find({}); 17 | 18 | if (!products) throw new Error("No product fetched"); 19 | 20 | // ======================== 1 SCRAPE LATEST PRODUCT DETAILS & UPDATE DB 21 | const updatedProducts = await Promise.all( 22 | products.map(async (currentProduct) => { 23 | // Scrape product 24 | const scrapedProduct = await scrapeAmazonProduct(currentProduct.url); 25 | 26 | if (!scrapedProduct) return; 27 | 28 | const updatedPriceHistory = [ 29 | ...currentProduct.priceHistory, 30 | { 31 | price: scrapedProduct.currentPrice, 32 | }, 33 | ]; 34 | 35 | const product = { 36 | ...scrapedProduct, 37 | priceHistory: updatedPriceHistory, 38 | lowestPrice: getLowestPrice(updatedPriceHistory), 39 | highestPrice: getHighestPrice(updatedPriceHistory), 40 | averagePrice: getAveragePrice(updatedPriceHistory), 41 | }; 42 | 43 | // Update Products in DB 44 | const updatedProduct = await Product.findOneAndUpdate( 45 | { 46 | url: product.url, 47 | }, 48 | product 49 | ); 50 | 51 | // ======================== 2 CHECK EACH PRODUCT'S STATUS & SEND EMAIL ACCORDINGLY 52 | const emailNotifType = getEmailNotifType(scrapedProduct, currentProduct); 53 | 54 | if (emailNotifType && updatedProduct.users.length > 0) { 55 | const productInfo = { 56 | title: updatedProduct.title, 57 | url: updatedProduct.url, 58 | }; 59 | // Construct emailContent 60 | const emailContent = await generateEmailBody( 61 | productInfo, 62 | emailNotifType 63 | ); 64 | // Get array of user emails 65 | const userEmails = updatedProduct.users.map( 66 | (user: any) => user.email 67 | ); 68 | // Send email notification 69 | await sendEmail(emailContent, userEmails); 70 | } 71 | 72 | return updatedProduct; 73 | }) 74 | ); 75 | 76 | return NextResponse.json({ 77 | message: "Ok", 78 | data: updatedProducts, 79 | }); 80 | } catch (error: any) { 81 | throw new Error(`Failed to get all products: ${error.message}`); 82 | } 83 | } -------------------------------------------------------------------------------- /lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import Product from "../models/product.model"; 5 | import { connectToDB } from "../mongoose"; 6 | import { scrapeAmazonProduct } from "../scraper"; 7 | import { getAveragePrice, getHighestPrice, getLowestPrice } from "../utils"; 8 | import { User } from "@/types"; 9 | import { generateEmailBody, sendEmail } from "../nodemailer"; 10 | 11 | export async function scrapeAndStoreProduct(productUrl: string) { 12 | if (!productUrl) return; 13 | 14 | try { 15 | connectToDB(); 16 | 17 | const scrapedProduct = await scrapeAmazonProduct(productUrl); 18 | 19 | if (!scrapedProduct) return; 20 | 21 | let product = scrapedProduct; 22 | 23 | const existingProduct = await Product.findOne({ url: scrapedProduct.url }); 24 | 25 | if (existingProduct) { 26 | const updatedPriceHistory: any = [ 27 | ...existingProduct.priceHistory, 28 | { price: scrapedProduct.currentPrice }, 29 | ]; 30 | 31 | product = { 32 | ...scrapedProduct, 33 | priceHistory: updatedPriceHistory, 34 | lowestPrice: getLowestPrice(updatedPriceHistory), 35 | highestPrice: getHighestPrice(updatedPriceHistory), 36 | averagePrice: getAveragePrice(updatedPriceHistory), 37 | }; 38 | } 39 | 40 | const newProduct = await Product.findOneAndUpdate( 41 | { url: scrapedProduct.url }, 42 | product, 43 | { upsert: true, new: true } 44 | ); 45 | 46 | revalidatePath(`/products/${newProduct._id}`); 47 | } catch (error: any) { 48 | throw new Error(`Failed to create/update product: ${error.message}`); 49 | } 50 | } 51 | 52 | export async function getProductById(productId: string) { 53 | try { 54 | connectToDB(); 55 | 56 | const product = await Product.findOne({ _id: productId }); 57 | 58 | if (!product) return null; 59 | 60 | return product; 61 | } catch (error) { 62 | console.log(error); 63 | } 64 | } 65 | 66 | export async function getAllProducts() { 67 | try { 68 | connectToDB(); 69 | 70 | const products = await Product.find(); 71 | 72 | return products; 73 | } catch (error) { 74 | console.log(error); 75 | } 76 | } 77 | 78 | export async function getSimilarProducts(productId: string) { 79 | try { 80 | connectToDB(); 81 | 82 | const currentProduct = await Product.findById(productId); 83 | 84 | if (!currentProduct) return null; 85 | 86 | const similarProducts = await Product.find({ 87 | _id: { $ne: productId }, 88 | }).limit(3); 89 | 90 | return similarProducts; 91 | } catch (error) { 92 | console.log(error); 93 | } 94 | } 95 | 96 | export async function addUserEmailToProduct(productId: string, userEmail: string) { 97 | try { 98 | const product = await Product.findById(productId); 99 | 100 | if(!product) return; 101 | 102 | const userExists = product.users.some((user: User) => user.email === userEmail); 103 | 104 | if(!userExists) { 105 | product.users.push({ email: userEmail }); 106 | 107 | await product.save(); 108 | 109 | const emailContent = await generateEmailBody(product, "WELCOME"); 110 | 111 | await sendEmail(emailContent, [userEmail]); 112 | } 113 | } catch (error) { 114 | console.log(error); 115 | } 116 | } -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { PriceHistoryItem, Product } from "@/types"; 2 | 3 | const Notification = { 4 | WELCOME: "WELCOME", 5 | CHANGE_OF_STOCK: "CHANGE_OF_STOCK", 6 | LOWEST_PRICE: "LOWEST_PRICE", 7 | THRESHOLD_MET: "THRESHOLD_MET", 8 | }; 9 | 10 | const THRESHOLD_PERCENTAGE = 40; 11 | 12 | // Extracts and returns the price from a list of possible elements. 13 | export function extractPrice(...elements: any) { 14 | for (const element of elements) { 15 | const priceText = element.text().trim(); 16 | 17 | if (priceText) { 18 | const cleanPrice = priceText.replace(/[^\d.]/g, ""); 19 | 20 | let firstPrice; 21 | 22 | if (cleanPrice) { 23 | firstPrice = cleanPrice.match(/\d+\.\d{2}/)?.[0]; 24 | } 25 | 26 | return firstPrice || cleanPrice; 27 | } 28 | } 29 | 30 | return ""; 31 | } 32 | 33 | // Extracts and returns the currency symbol from an element. 34 | export function extractCurrency(element: any) { 35 | const currencyText = element.text().trim().slice(0, 1); 36 | return currencyText ? currencyText : ""; 37 | } 38 | 39 | // Extracts description from two possible elements from amazon 40 | export function extractDescription($: any) { 41 | // these are possible elements holding description of the product 42 | const selectors = [ 43 | ".a-unordered-list .a-list-item", 44 | ".a-expander-content p", 45 | // Add more selectors here if needed 46 | ]; 47 | 48 | for (const selector of selectors) { 49 | const elements = $(selector); 50 | if (elements.length > 0) { 51 | const textContent = elements 52 | .map((_: any, element: any) => $(element).text().trim()) 53 | .get() 54 | .join("\n"); 55 | return textContent; 56 | } 57 | } 58 | 59 | // If no matching elements were found, return an empty string 60 | return ""; 61 | } 62 | 63 | export function getHighestPrice(priceList: PriceHistoryItem[]) { 64 | let highestPrice = priceList[0]; 65 | 66 | for (let i = 0; i < priceList.length; i++) { 67 | if (priceList[i].price > highestPrice.price) { 68 | highestPrice = priceList[i]; 69 | } 70 | } 71 | 72 | return highestPrice.price; 73 | } 74 | 75 | export function getLowestPrice(priceList: PriceHistoryItem[]) { 76 | let lowestPrice = priceList[0]; 77 | 78 | for (let i = 0; i < priceList.length; i++) { 79 | if (priceList[i].price < lowestPrice.price) { 80 | lowestPrice = priceList[i]; 81 | } 82 | } 83 | 84 | return lowestPrice.price; 85 | } 86 | 87 | export function getAveragePrice(priceList: PriceHistoryItem[]) { 88 | const sumOfPrices = priceList.reduce((acc, curr) => acc + curr.price, 0); 89 | const averagePrice = sumOfPrices / priceList.length || 0; 90 | 91 | return averagePrice; 92 | } 93 | 94 | export const getEmailNotifType = ( 95 | scrapedProduct: Product, 96 | currentProduct: Product 97 | ) => { 98 | const lowestPrice = getLowestPrice(currentProduct.priceHistory); 99 | 100 | if (scrapedProduct.currentPrice < lowestPrice) { 101 | return Notification.LOWEST_PRICE as keyof typeof Notification; 102 | } 103 | if (!scrapedProduct.isOutOfStock && currentProduct.isOutOfStock) { 104 | return Notification.CHANGE_OF_STOCK as keyof typeof Notification; 105 | } 106 | if (scrapedProduct.discountRate >= THRESHOLD_PERCENTAGE) { 107 | return Notification.THRESHOLD_MET as keyof typeof Notification; 108 | } 109 | 110 | return null; 111 | }; 112 | 113 | export const formatNumber = (num: number = 0) => { 114 | return num.toLocaleString(undefined, { 115 | minimumFractionDigits: 0, 116 | maximumFractionDigits: 0, 117 | }); 118 | }; -------------------------------------------------------------------------------- /lib/nodemailer/index.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { EmailContent, EmailProductInfo, NotificationType } from "@/types"; 4 | import nodemailer from "nodemailer"; 5 | 6 | const Notification = { 7 | WELCOME: "WELCOME", 8 | CHANGE_OF_STOCK: "CHANGE_OF_STOCK", 9 | LOWEST_PRICE: "LOWEST_PRICE", 10 | THRESHOLD_MET: "THRESHOLD_MET", 11 | }; 12 | 13 | export async function generateEmailBody( 14 | product: EmailProductInfo, 15 | type: NotificationType 16 | ) { 17 | const THRESHOLD_PERCENTAGE = 40; 18 | // Shorten the product title 19 | const shortenedTitle = 20 | product.title.length > 20 21 | ? `${product.title.substring(0, 20)}...` 22 | : product.title; 23 | 24 | let subject = ""; 25 | let body = ""; 26 | 27 | switch (type) { 28 | case Notification.WELCOME: 29 | subject = `Welcome to Price Tracking for ${shortenedTitle}`; 30 | body = ` 31 |
32 |

Welcome to PriceTracker 🚀

33 |

You are now tracking ${product.title}.

34 |

Here's an example of how you'll receive updates:

35 |
36 |

${product.title} is back in stock!

37 |

We're excited to let you know that ${product.title} is now back in stock.

38 |

Don't miss out - buy it now!

39 | Product Image 40 |
41 |

Stay tuned for more updates on ${product.title} and other products you're tracking.

42 |
43 | `; 44 | break; 45 | 46 | case Notification.CHANGE_OF_STOCK: 47 | subject = `${shortenedTitle} is now back in stock!`; 48 | body = ` 49 |
50 |

Hey, ${product.title} is now restocked! Grab yours before they run out again!

51 |

See the product here.

52 |
53 | `; 54 | break; 55 | 56 | case Notification.LOWEST_PRICE: 57 | subject = `Lowest Price Alert for ${shortenedTitle}`; 58 | body = ` 59 |
60 |

Hey, ${product.title} has reached its lowest price ever!!

61 |

Grab the product here now.

62 |
63 | `; 64 | break; 65 | 66 | case Notification.THRESHOLD_MET: 67 | subject = `Discount Alert for ${shortenedTitle}`; 68 | body = ` 69 |
70 |

Hey, ${product.title} is now available at a discount more than ${THRESHOLD_PERCENTAGE}%!

71 |

Grab it right away from here.

72 |
73 | `; 74 | break; 75 | 76 | default: 77 | throw new Error("Invalid notification type."); 78 | } 79 | 80 | return { subject, body }; 81 | } 82 | 83 | const transporter = nodemailer.createTransport({ 84 | pool: true, 85 | service: "hotmail", 86 | port: 2525, 87 | auth: { 88 | user: "ancientsoftwaredeveloper@outlook.com", 89 | pass: process.env.EMAIL_PASSWORD, 90 | }, 91 | maxConnections: 1, 92 | }); 93 | 94 | export const sendEmail = async ( 95 | emailContent: EmailContent, 96 | sendTo: string[] 97 | ) => { 98 | const mailOptions = { 99 | from: "ancientsoftwaredeveloper@outlook.com", 100 | to: sendTo, 101 | html: emailContent.body, 102 | subject: emailContent.subject, 103 | }; 104 | 105 | transporter.sendMail(mailOptions, (error: any, info: any) => { 106 | if (error) return console.log(error); 107 | 108 | console.log("Email sent: ", info); 109 | }); 110 | }; 111 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | scroll-behavior: smooth; 10 | } 11 | 12 | @layer base { 13 | body { 14 | @apply font-inter; 15 | } 16 | } 17 | 18 | @layer utilities { 19 | .btn { 20 | @apply py-4 px-4 bg-secondary hover:bg-opacity-70 rounded-[30px] text-white text-lg font-semibold; 21 | } 22 | 23 | .head-text { 24 | @apply mt-4 text-6xl leading-[72px] font-bold tracking-[-1.2px] text-gray-900; 25 | } 26 | 27 | .section-text { 28 | @apply text-secondary text-[32px] font-semibold; 29 | } 30 | 31 | .small-text { 32 | @apply flex gap-2 text-sm font-medium text-primary; 33 | } 34 | 35 | .paragraph-text { 36 | @apply text-xl leading-[30px] text-gray-600; 37 | } 38 | 39 | .hero-carousel { 40 | @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; 41 | } 42 | 43 | .carousel { 44 | @apply flex flex-col-reverse h-[700px]; 45 | } 46 | 47 | .carousel .control-dots { 48 | @apply static !important; 49 | } 50 | 51 | .carousel .control-dots .dot { 52 | @apply w-[10px] h-[10px] bg-[#D9D9D9] rounded-full bottom-0 !important; 53 | } 54 | 55 | .carousel .control-dots .dot.selected { 56 | @apply bg-[#475467] !important; 57 | } 58 | 59 | .trending-section { 60 | @apply flex flex-col gap-10 px-6 md:px-20 py-24; 61 | } 62 | 63 | /* PRODUCT DETAILS PAGE STYLES */ 64 | .product-container { 65 | @apply flex flex-col gap-16 flex-wrap px-6 md:px-20 py-24; 66 | } 67 | 68 | .product-image { 69 | @apply flex-grow xl:max-w-[50%] max-w-full py-16 border border-[#CDDBFF] rounded-[17px]; 70 | } 71 | 72 | .product-info { 73 | @apply flex items-center flex-wrap gap-10 py-6 border-y border-y-[#E4E4E4]; 74 | } 75 | 76 | .product-hearts { 77 | @apply flex items-center gap-2 px-3 py-2 bg-[#FFF0F0] rounded-10; 78 | } 79 | 80 | .product-stars { 81 | @apply flex items-center gap-2 px-3 py-2 bg-[#FBF3EA] rounded-[27px]; 82 | } 83 | 84 | .product-reviews { 85 | @apply flex items-center gap-2 px-3 py-2 bg-white-200 rounded-[27px]; 86 | } 87 | 88 | /* MODAL */ 89 | .dialog-container { 90 | @apply fixed inset-0 z-10 overflow-y-auto bg-black bg-opacity-60; 91 | } 92 | 93 | .dialog-content { 94 | @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; 95 | } 96 | 97 | .dialog-head_text { 98 | @apply text-secondary text-lg leading-[24px] font-semibold mt-4; 99 | } 100 | 101 | .dialog-input_container { 102 | @apply px-5 py-3 mt-3 flex items-center gap-2 border border-gray-300 rounded-[27px]; 103 | } 104 | 105 | .dialog-input { 106 | @apply flex-1 pl-1 border-none text-gray-500 text-base focus:outline-none border border-gray-300 rounded-[27px] shadow-xs; 107 | } 108 | 109 | .dialog-btn { 110 | @apply px-5 py-3 text-white text-base font-semibold border border-secondary bg-secondary rounded-lg mt-8; 111 | } 112 | 113 | /* NAVBAR */ 114 | .nav { 115 | @apply flex justify-between items-center px-6 md:px-20 py-4; 116 | } 117 | 118 | .nav-logo { 119 | @apply font-spaceGrotesk text-[21px] text-secondary font-bold; 120 | } 121 | 122 | /* PRICE INFO */ 123 | .price-info_card { 124 | @apply flex-1 min-w-[200px] flex flex-col gap-2 border-l-[3px] rounded-10 bg-white-100 px-5 py-4; 125 | } 126 | 127 | /* PRODUCT CARD */ 128 | .product-card { 129 | @apply sm:w-[292px] sm:max-w-[292px] w-full flex-1 flex flex-col gap-4 rounded-md; 130 | } 131 | 132 | .product-card_img-container { 133 | @apply flex-1 relative flex flex-col gap-5 p-4 rounded-md; 134 | } 135 | 136 | .product-card_img { 137 | @apply max-h-[250px] object-contain w-full h-full bg-transparent; 138 | } 139 | 140 | .product-title { 141 | @apply text-secondary text-xl leading-6 font-semibold truncate; 142 | } 143 | 144 | /* SEARCHBAR INPUT */ 145 | .searchbar-input { 146 | @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; 147 | } 148 | 149 | .searchbar-btn { 150 | @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; 151 | } 152 | } -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FormEvent, Fragment, useState } from "react"; 4 | import { Dialog, Transition } from "@headlessui/react"; 5 | import Image from "next/image"; 6 | import { addUserEmailToProduct } from '@/lib/actions' 7 | 8 | interface Props { 9 | productId: string; 10 | } 11 | 12 | const Modal = ({ productId }: Props) => { 13 | let [isOpen, setIsOpen] = useState(true); 14 | const [isSubmitting, setIsSubmitting] = useState(false); 15 | const [email, setEmail] = useState(''); 16 | 17 | const handleSubmit = async (e: FormEvent) => { 18 | e.preventDefault(); 19 | setIsSubmitting(true); 20 | 21 | await addUserEmailToProduct(productId, email); 22 | 23 | setIsSubmitting(false); 24 | setEmail(''); 25 | closeModal(); 26 | }; 27 | 28 | const openModal = () => setIsOpen(true); 29 | 30 | const closeModal = () => setIsOpen(false); 31 | 32 | return ( 33 | <> 34 | 37 | 38 | 39 | 40 |
41 | 50 | 51 | 52 | 53 |
132 |
133 |
134 | 135 | ); 136 | }; 137 | 138 | export default Modal -------------------------------------------------------------------------------- /app/products/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@/components/Modal"; 2 | import PriceInfoCard from "@/components/PriceInfoCard"; 3 | import ProductCard from "@/components/ProductCard"; 4 | import { getProductById, getSimilarProducts } from "@/lib/actions"; 5 | import { formatNumber } from "@/lib/utils"; 6 | import { Product } from "@/types"; 7 | import Image from "next/image"; 8 | import Link from "next/link"; 9 | import { redirect } from "next/navigation"; 10 | 11 | type Props = { 12 | params: { id: string }; 13 | }; 14 | 15 | const ProductDetails = async ({ params: { id } }: Props) => { 16 | const product: Product = await getProductById(id); 17 | 18 | if (!product) redirect("/"); 19 | 20 | const similarProducts = await getSimilarProducts(id); 21 | 22 | return ( 23 |
24 |
25 |
26 | {product.title} 33 |
34 | 35 |
36 |
37 |
38 |

39 | {product.title} 40 |

41 | 42 | 47 | Visit Product 48 | 49 |
50 | 51 |
52 |
53 | heart 59 | 60 |

61 | {product.reviewsCount} 62 |

63 |
64 | 65 |
66 | bookmark 72 |
73 | 74 |
75 | share 81 |
82 |
83 |
84 | 85 |
86 |
87 |

88 | {product.currency} {formatNumber(product.currentPrice)} 89 |

90 |

91 | {product.currency} {formatNumber(product.originalPrice)} 92 |

93 |
94 | 95 |
96 |
97 |
98 | star 104 |

105 | {product.stars || "25"} 106 |

107 |
108 | 109 |
110 | comment 116 |

117 | {product.reviewsCount} Reviews 118 |

119 |
120 |
121 | 122 |

123 | 93% {" "} 124 | of buyers have recommeded this. 125 |

126 |
127 |
128 | 129 |
130 |
131 | 138 | 145 | 152 | 159 |
160 |
161 | 162 | 163 |
164 |
165 | 166 |
167 |
168 |

169 | Product Description 170 |

171 | 172 |
173 | {product?.description?.split("\n")} 174 |
175 |
176 | 177 | 189 |
190 | 191 | {similarProducts && similarProducts?.length > 0 && ( 192 |
193 |

Similar Products

194 | 195 |
196 | {similarProducts.map((product) => ( 197 | 198 | ))} 199 |
200 |
201 | )} 202 |
203 | ); 204 | }; 205 | 206 | export default ProductDetails -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Project Banner 5 | 6 |
7 | 8 |
9 | webscraping 10 | nextjs 11 | tailwindcss 12 | mongodb 13 |
14 | 15 |

A Ecom Price Tracking Application

16 | 17 |
18 | Build this project step by step with our detailed. 19 |
20 |
21 | 22 | ## 📋 Table of Contents 23 | 24 | 1. 🤖 [Introduction](#introduction) 25 | 2. ⚙️ [Tech Stack](#tech-stack) 26 | 3. 🔋 [Features](#features) 27 | 4. 🤸 [Quick Start](#quick-start) 28 | 5. 🕸️ [Snippets](#snippets) 29 | 6. 🔗 [Links](#links) 30 | 31 | ## 🚨 Tutorial 32 | 33 | This repository contains the code that corresponds to building an app from scratch. 34 | . 35 | 36 | If you prefer to learn from the doc, this is the perfect resource for you. Follow along to learn how to create projects like these step by step in a beginner-friendly way! 37 | 38 | ## 🤖 Introduction 39 | 40 | Developed 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. 41 | 42 | If you are just starting out and need help, or if you encounter any bugs, you can ask. This is a place where people help each other. 43 | 44 | ## ⚙️ Tech Stack 45 | 46 | - Next.js 47 | - Bright Data 48 | - Cheerio 49 | - Nodemailer 50 | - MongoDB 51 | - Headless UI 52 | - Tailwind CSS 53 | 54 | ## 🔋 Features 55 | 56 | 👉 **Header with Carousel**: Visually appealing header with a carousel showcasing key features and benefits 57 | 58 | 👉 **Product Scraping**: A search bar allowing users to input Amazon product links for scraping. 59 | 60 | 👉 **Scraped Projects**: Displays the details of products scraped so far, offering insights into tracked items. 61 | 62 | 👉 **Scraped Product Details**: Showcase the product image, title, pricing, details, and other relevant information scraped from the original website 63 | 64 | 👉 **Track Option**: Modal for users to provide email addresses and opt-in for tracking. 65 | 66 | 👉 **Email Notifications**: Send emails product alert emails for various scenarios, e.g., back in stock alerts or lowest price notifications. 67 | 68 | 👉 **Automated Cron Jobs**: Utilize cron jobs to automate periodic scraping, ensuring data is up-to-date. 69 | 70 | and many more, including code architecture and reusability 71 | 72 | ## 🤸 Quick Start 73 | 74 | Follow these steps to set up the project locally on your machine. 75 | 76 | **Prerequisites** 77 | 78 | Make sure you have the following installed on your machine: 79 | 80 | - [Git](https://git-scm.com/) 81 | - [Node.js](https://nodejs.org/en) 82 | - [npm](https://www.npmjs.com/) (Node Package Manager) 83 | 84 | **Cloning the Repository** 85 | 86 | ```bash 87 | git clone https://github.com/emredkyc/price_tracker.git 88 | cd price_tracker 89 | ``` 90 | 91 | **Installation** 92 | 93 | Install the project dependencies using npm: 94 | 95 | ```bash 96 | npm install 97 | ``` 98 | 99 | **Set Up Environment Variables** 100 | 101 | Create a new file named `.env` in the root of your project and add the following content: 102 | 103 | ```env 104 | #SCRAPER 105 | BRIGHT_DATA_USERNAME= 106 | BRIGHT_DATA_PASSWORD= 107 | 108 | #DB 109 | MONGODB_URI= 110 | 111 | #OUTLOOK 112 | EMAIL_USER= 113 | EMAIL_PASS= 114 | ``` 115 | 116 | Replace 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/) 117 | 118 | **Running the Project** 119 | 120 | ```bash 121 | npm run dev 122 | ``` 123 | 124 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. 125 | 126 | ## 🕸️ Snippets 127 | 128 |
129 | cron.route.ts 130 | 131 | ```typescript 132 | import { NextResponse } from "next/server"; 133 | 134 | import { getLowestPrice, getHighestPrice, getAveragePrice, getEmailNotifType } from "@/lib/utils"; 135 | import { connectToDB } from "@/lib/mongoose"; 136 | import Product from "@/lib/models/product.model"; 137 | import { scrapeAmazonProduct } from "@/lib/scraper"; 138 | import { generateEmailBody, sendEmail } from "@/lib/nodemailer"; 139 | 140 | export const maxDuration = 300; // This function can run for a maximum of 300 seconds 141 | export const dynamic = "force-dynamic"; 142 | export const revalidate = 0; 143 | 144 | export async function GET(request: Request) { 145 | try { 146 | connectToDB(); 147 | 148 | const products = await Product.find({}); 149 | 150 | if (!products) throw new Error("No product fetched"); 151 | 152 | // ======================== 1 SCRAPE LATEST PRODUCT DETAILS & UPDATE DB 153 | const updatedProducts = await Promise.all( 154 | products.map(async (currentProduct) => { 155 | // Scrape product 156 | const scrapedProduct = await scrapeAmazonProduct(currentProduct.url); 157 | 158 | if (!scrapedProduct) return; 159 | 160 | const updatedPriceHistory = [ 161 | ...currentProduct.priceHistory, 162 | { 163 | price: scrapedProduct.currentPrice, 164 | }, 165 | ]; 166 | 167 | const product = { 168 | ...scrapedProduct, 169 | priceHistory: updatedPriceHistory, 170 | lowestPrice: getLowestPrice(updatedPriceHistory), 171 | highestPrice: getHighestPrice(updatedPriceHistory), 172 | averagePrice: getAveragePrice(updatedPriceHistory), 173 | }; 174 | 175 | // Update Products in DB 176 | const updatedProduct = await Product.findOneAndUpdate( 177 | { 178 | url: product.url, 179 | }, 180 | product 181 | ); 182 | 183 | // ======================== 2 CHECK EACH PRODUCT'S STATUS & SEND EMAIL ACCORDINGLY 184 | const emailNotifType = getEmailNotifType( 185 | scrapedProduct, 186 | currentProduct 187 | ); 188 | 189 | if (emailNotifType && updatedProduct.users.length > 0) { 190 | const productInfo = { 191 | title: updatedProduct.title, 192 | url: updatedProduct.url, 193 | }; 194 | // Construct emailContent 195 | const emailContent = await generateEmailBody(productInfo, emailNotifType); 196 | // Get array of user emails 197 | const userEmails = updatedProduct.users.map((user: any) => user.email); 198 | // Send email notification 199 | await sendEmail(emailContent, userEmails); 200 | } 201 | 202 | return updatedProduct; 203 | }) 204 | ); 205 | 206 | return NextResponse.json({ 207 | message: "Ok", 208 | data: updatedProducts, 209 | }); 210 | } catch (error: any) { 211 | throw new Error(`Failed to get all products: ${error.message}`); 212 | } 213 | } 214 | ``` 215 | 216 |
217 | 218 |
219 | generateEmailBody.ts 220 | 221 | ```typescript 222 | export async function generateEmailBody( 223 | product: EmailProductInfo, 224 | type: NotificationType 225 | ) { 226 | const THRESHOLD_PERCENTAGE = 40; 227 | // Shorten the product title 228 | const shortenedTitle = 229 | product.title.length > 20 230 | ? `${product.title.substring(0, 20)}...` 231 | : product.title; 232 | 233 | let subject = ""; 234 | let body = ""; 235 | 236 | switch (type) { 237 | case Notification.WELCOME: 238 | subject = `Welcome to Price Tracking for ${shortenedTitle}`; 239 | body = ` 240 |
241 |

Welcome to PriceTracker 🚀

242 |

You are now tracking ${product.title}.

243 |

Here's an example of how you'll receive updates:

244 |
245 |

${product.title} is back in stock!

246 |

We're excited to let you know that ${product.title} is now back in stock.

247 |

Don't miss out - buy it now!

248 | Product Image 249 |
250 |

Stay tuned for more updates on ${product.title} and other products you're tracking.

251 |
252 | `; 253 | break; 254 | 255 | case Notification.CHANGE_OF_STOCK: 256 | subject = `${shortenedTitle} is now back in stock!`; 257 | body = ` 258 |
259 |

Hey, ${product.title} is now restocked! Grab yours before they run out again!

260 |

See the product here.

261 |
262 | `; 263 | break; 264 | 265 | case Notification.LOWEST_PRICE: 266 | subject = `Lowest Price Alert for ${shortenedTitle}`; 267 | body = ` 268 |
269 |

Hey, ${product.title} has reached its lowest price ever!!

270 |

Grab the product here now.

271 |
272 | `; 273 | break; 274 | 275 | case Notification.THRESHOLD_MET: 276 | subject = `Discount Alert for ${shortenedTitle}`; 277 | body = ` 278 |
279 |

Hey, ${product.title} is now available at a discount more than ${THRESHOLD_PERCENTAGE}%!

280 |

Grab it right away from here.

281 |
282 | `; 283 | break; 284 | 285 | default: 286 | throw new Error("Invalid notification type."); 287 | } 288 | 289 | return { subject, body }; 290 | } 291 | ``` 292 | 293 |
294 | 295 |
296 | globals.css 297 | 298 | ```css 299 | @tailwind base; 300 | @tailwind components; 301 | @tailwind utilities; 302 | 303 | * { 304 | margin: 0; 305 | padding: 0; 306 | box-sizing: border-box; 307 | scroll-behavior: smooth; 308 | } 309 | 310 | @layer base { 311 | body { 312 | @apply font-inter; 313 | } 314 | } 315 | 316 | @layer utilities { 317 | .btn { 318 | @apply py-4 px-4 bg-secondary hover:bg-opacity-70 rounded-[30px] text-white text-lg font-semibold; 319 | } 320 | 321 | .head-text { 322 | @apply mt-4 text-6xl leading-[72px] font-bold tracking-[-1.2px] text-gray-900; 323 | } 324 | 325 | .section-text { 326 | @apply text-secondary text-[32px] font-semibold; 327 | } 328 | 329 | .small-text { 330 | @apply flex gap-2 text-sm font-medium text-primary; 331 | } 332 | 333 | .paragraph-text { 334 | @apply text-xl leading-[30px] text-gray-600; 335 | } 336 | 337 | .hero-carousel { 338 | @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; 339 | } 340 | 341 | .carousel { 342 | @apply flex flex-col-reverse h-[700px]; 343 | } 344 | 345 | .carousel .control-dots { 346 | @apply static !important; 347 | } 348 | 349 | .carousel .control-dots .dot { 350 | @apply w-[10px] h-[10px] bg-[#D9D9D9] rounded-full bottom-0 !important; 351 | } 352 | 353 | .carousel .control-dots .dot.selected { 354 | @apply bg-[#475467] !important; 355 | } 356 | 357 | .trending-section { 358 | @apply flex flex-col gap-10 px-6 md:px-20 py-24; 359 | } 360 | 361 | /* PRODUCT DETAILS PAGE STYLES */ 362 | .product-container { 363 | @apply flex flex-col gap-16 flex-wrap px-6 md:px-20 py-24; 364 | } 365 | 366 | .product-image { 367 | @apply flex-grow xl:max-w-[50%] max-w-full py-16 border border-[#CDDBFF] rounded-[17px]; 368 | } 369 | 370 | .product-info { 371 | @apply flex items-center flex-wrap gap-10 py-6 border-y border-y-[#E4E4E4]; 372 | } 373 | 374 | .product-hearts { 375 | @apply flex items-center gap-2 px-3 py-2 bg-[#FFF0F0] rounded-10; 376 | } 377 | 378 | .product-stars { 379 | @apply flex items-center gap-2 px-3 py-2 bg-[#FBF3EA] rounded-[27px]; 380 | } 381 | 382 | .product-reviews { 383 | @apply flex items-center gap-2 px-3 py-2 bg-white-200 rounded-[27px]; 384 | } 385 | 386 | /* MODAL */ 387 | .dialog-container { 388 | @apply fixed inset-0 z-10 overflow-y-auto bg-black bg-opacity-60; 389 | } 390 | 391 | .dialog-content { 392 | @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; 393 | } 394 | 395 | .dialog-head_text { 396 | @apply text-secondary text-lg leading-[24px] font-semibold mt-4; 397 | } 398 | 399 | .dialog-input_container { 400 | @apply px-5 py-3 mt-3 flex items-center gap-2 border border-gray-300 rounded-[27px]; 401 | } 402 | 403 | .dialog-input { 404 | @apply flex-1 pl-1 border-none text-gray-500 text-base focus:outline-none border border-gray-300 rounded-[27px] shadow-xs; 405 | } 406 | 407 | .dialog-btn { 408 | @apply px-5 py-3 text-white text-base font-semibold border border-secondary bg-secondary rounded-lg mt-8; 409 | } 410 | 411 | /* NAVBAR */ 412 | .nav { 413 | @apply flex justify-between items-center px-6 md:px-20 py-4; 414 | } 415 | 416 | .nav-logo { 417 | @apply font-spaceGrotesk text-[21px] text-secondary font-bold; 418 | } 419 | 420 | /* PRICE INFO */ 421 | .price-info_card { 422 | @apply flex-1 min-w-[200px] flex flex-col gap-2 border-l-[3px] rounded-10 bg-white-100 px-5 py-4; 423 | } 424 | 425 | /* PRODUCT CARD */ 426 | .product-card { 427 | @apply sm:w-[292px] sm:max-w-[292px] w-full flex-1 flex flex-col gap-4 rounded-md; 428 | } 429 | 430 | .product-card_img-container { 431 | @apply flex-1 relative flex flex-col gap-5 p-4 rounded-md; 432 | } 433 | 434 | .product-card_img { 435 | @apply max-h-[250px] object-contain w-full h-full bg-transparent; 436 | } 437 | 438 | .product-title { 439 | @apply text-secondary text-xl leading-6 font-semibold truncate; 440 | } 441 | 442 | /* SEARCHBAR INPUT */ 443 | .searchbar-input { 444 | @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; 445 | } 446 | 447 | .searchbar-btn { 448 | @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; 449 | } 450 | } 451 | ``` 452 | 453 |
454 | 455 |
456 | index.scraper.ts 457 | 458 | ```typescript 459 | "use server" 460 | 461 | import axios from 'axios'; 462 | import * as cheerio from 'cheerio'; 463 | import { extractCurrency, extractDescription, extractPrice } from '../utils'; 464 | 465 | export async function scrapeAmazonProduct(url: string) { 466 | if(!url) return; 467 | 468 | // BrightData proxy configuration 469 | const username = String(process.env.BRIGHT_DATA_USERNAME); 470 | const password = String(process.env.BRIGHT_DATA_PASSWORD); 471 | const port = 22225; 472 | const session_id = (1000000 * Math.random()) | 0; 473 | 474 | const options = { 475 | auth: { 476 | username: `${username}-session-${session_id}`, 477 | password, 478 | }, 479 | host: 'brd.superproxy.io', 480 | port, 481 | rejectUnauthorized: false, 482 | } 483 | 484 | try { 485 | // Fetch the product page 486 | const response = await axios.get(url, options); 487 | const $ = cheerio.load(response.data); 488 | 489 | // Extract the product title 490 | const title = $('#productTitle').text().trim(); 491 | const currentPrice = extractPrice( 492 | $('.priceToPay span.a-price-whole'), 493 | $('.a.size.base.a-color-price'), 494 | $('.a-button-selected .a-color-base'), 495 | ); 496 | 497 | const originalPrice = extractPrice( 498 | $('#priceblock_ourprice'), 499 | $('.a-price.a-text-price span.a-offscreen'), 500 | $('#listPrice'), 501 | $('#priceblock_dealprice'), 502 | $('.a-size-base.a-color-price') 503 | ); 504 | 505 | const outOfStock = $('#availability span').text().trim().toLowerCase() === 'currently unavailable'; 506 | 507 | const images = 508 | $('#imgBlkFront').attr('data-a-dynamic-image') || 509 | $('#landingImage').attr('data-a-dynamic-image') || 510 | '{}' 511 | 512 | const imageUrls = Object.keys(JSON.parse(images)); 513 | 514 | const currency = extractCurrency($('.a-price-symbol')) 515 | const discountRate = $('.savingsPercentage').text().replace(/[-%]/g, ""); 516 | 517 | const description = extractDescription($) 518 | 519 | // Construct data object with scraped information 520 | const data = { 521 | url, 522 | currency: currency || '$', 523 | image: imageUrls[0], 524 | title, 525 | currentPrice: Number(currentPrice) || Number(originalPrice), 526 | originalPrice: Number(originalPrice) || Number(currentPrice), 527 | priceHistory: [], 528 | discountRate: Number(discountRate), 529 | category: 'category', 530 | reviewsCount:100, 531 | stars: 4.5, 532 | isOutOfStock: outOfStock, 533 | description, 534 | lowestPrice: Number(currentPrice) || Number(originalPrice), 535 | highestPrice: Number(originalPrice) || Number(currentPrice), 536 | averagePrice: Number(currentPrice) || Number(originalPrice), 537 | } 538 | 539 | return data; 540 | } catch (error: any) { 541 | console.log(error); 542 | } 543 | } 544 | ``` 545 | 546 |
547 | 548 |
549 | next.config.js 550 | 551 | ```javascript 552 | /** @type {import('next').NextConfig} */ 553 | const nextConfig = { 554 | experimental: { 555 | serverActions: true, 556 | serverComponentsExternalPackages: ['mongoose'] 557 | }, 558 | images: { 559 | domains: ['m.media-amazon.com'] 560 | } 561 | } 562 | 563 | module.exports = nextConfig 564 | ``` 565 | 566 |
567 | 568 |
569 | tailwind.config.ts 570 | 571 | ```typescript 572 | /** @type {import('tailwindcss').Config} */ 573 | module.exports = { 574 | content: [ 575 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 576 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 577 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 578 | ], 579 | theme: { 580 | extend: { 581 | colors: { 582 | primary: { 583 | DEFAULT: "#E43030", 584 | "orange": "#D48D3B", 585 | "green": "#3E9242" 586 | }, 587 | secondary: "#282828", 588 | "gray-200": "#EAECF0", 589 | "gray-300": "D0D5DD", 590 | "gray-500": "#667085", 591 | "gray-600": "#475467", 592 | "gray-700": "#344054", 593 | "gray-900": "#101828", 594 | "white-100": "#F4F4F4", 595 | "white-200": "#EDF0F8", 596 | "black-100": "#3D4258", 597 | "neutral-black": "#23263B", 598 | }, 599 | boxShadow: { 600 | xs: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)", 601 | }, 602 | maxWidth: { 603 | "10xl": '1440px' 604 | }, 605 | fontFamily: { 606 | inter: ['Inter', 'sans-serif'], 607 | spaceGrotesk: ['Space Grotesk', 'sans-serif'], 608 | }, 609 | borderRadius: { 610 | 10: "10px" 611 | } 612 | }, 613 | }, 614 | plugins: [], 615 | }; 616 | ``` 617 | 618 |
619 | 620 |
621 | types.ts 622 | 623 | ```typescript 624 | export type PriceHistoryItem = { 625 | price: number; 626 | }; 627 | 628 | export type User = { 629 | email: string; 630 | }; 631 | 632 | export type Product = { 633 | _id?: string; 634 | url: string; 635 | currency: string; 636 | image: string; 637 | title: string; 638 | currentPrice: number; 639 | originalPrice: number; 640 | priceHistory: PriceHistoryItem[] | []; 641 | highestPrice: number; 642 | lowestPrice: number; 643 | averagePrice: number; 644 | discountRate: number; 645 | description: string; 646 | category: string; 647 | reviewsCount: number; 648 | stars: number; 649 | isOutOfStock: Boolean; 650 | users?: User[]; 651 | }; 652 | 653 | export type NotificationType = 654 | | "WELCOME" 655 | | "CHANGE_OF_STOCK" 656 | | "LOWEST_PRICE" 657 | | "THRESHOLD_MET"; 658 | 659 | export type EmailContent = { 660 | subject: string; 661 | body: string; 662 | }; 663 | 664 | export type EmailProductInfo = { 665 | title: string; 666 | url: string; 667 | }; 668 | ``` 669 | 670 |
671 | 672 |
673 | utils.ts 674 | 675 | ```typescript 676 | import { PriceHistoryItem, Product } from "@/types"; 677 | 678 | const Notification = { 679 | WELCOME: 'WELCOME', 680 | CHANGE_OF_STOCK: 'CHANGE_OF_STOCK', 681 | LOWEST_PRICE: 'LOWEST_PRICE', 682 | THRESHOLD_MET: 'THRESHOLD_MET', 683 | } 684 | 685 | const THRESHOLD_PERCENTAGE = 40; 686 | 687 | // Extracts and returns the price from a list of possible elements. 688 | export function extractPrice(...elements: any) { 689 | for (const element of elements) { 690 | const priceText = element.text().trim(); 691 | 692 | if(priceText) { 693 | const cleanPrice = priceText.replace(/[^\d.]/g, ''); 694 | 695 | let firstPrice; 696 | 697 | if (cleanPrice) { 698 | firstPrice = cleanPrice.match(/\d+\.\d{2}/)?.[0]; 699 | } 700 | 701 | return firstPrice || cleanPrice; 702 | } 703 | } 704 | 705 | return ''; 706 | } 707 | 708 | // Extracts and returns the currency symbol from an element. 709 | export function extractCurrency(element: any) { 710 | const currencyText = element.text().trim().slice(0, 1); 711 | return currencyText ? currencyText : ""; 712 | } 713 | 714 | // Extracts description from two possible elements from amazon 715 | export function extractDescription($: any) { 716 | // these are possible elements holding description of the product 717 | const selectors = [ 718 | ".a-unordered-list .a-list-item", 719 | ".a-expander-content p", 720 | // Add more selectors here if needed 721 | ]; 722 | 723 | for (const selector of selectors) { 724 | const elements = $(selector); 725 | if (elements.length > 0) { 726 | const textContent = elements 727 | .map((_: any, element: any) => $(element).text().trim()) 728 | .get() 729 | .join("\n"); 730 | return textContent; 731 | } 732 | } 733 | 734 | // If no matching elements were found, return an empty string 735 | return ""; 736 | } 737 | 738 | export function getHighestPrice(priceList: PriceHistoryItem[]) { 739 | let highestPrice = priceList[0]; 740 | 741 | for (let i = 0; i < priceList.length; i++) { 742 | if (priceList[i].price > highestPrice.price) { 743 | highestPrice = priceList[i]; 744 | } 745 | } 746 | 747 | return highestPrice.price; 748 | } 749 | 750 | export function getLowestPrice(priceList: PriceHistoryItem[]) { 751 | let lowestPrice = priceList[0]; 752 | 753 | for (let i = 0; i < priceList.length; i++) { 754 | if (priceList[i].price < lowestPrice.price) { 755 | lowestPrice = priceList[i]; 756 | } 757 | } 758 | 759 | return lowestPrice.price; 760 | } 761 | 762 | export function getAveragePrice(priceList: PriceHistoryItem[]) { 763 | const sumOfPrices = priceList.reduce((acc, curr) => acc + curr.price, 0); 764 | const averagePrice = sumOfPrices / priceList.length || 0; 765 | 766 | return averagePrice; 767 | } 768 | 769 | export const getEmailNotifType = ( 770 | scrapedProduct: Product, 771 | currentProduct: Product 772 | ) => { 773 | const lowestPrice = getLowestPrice(currentProduct.priceHistory); 774 | 775 | if (scrapedProduct.currentPrice < lowestPrice) { 776 | return Notification.LOWEST_PRICE as keyof typeof Notification; 777 | } 778 | if (!scrapedProduct.isOutOfStock && currentProduct.isOutOfStock) { 779 | return Notification.CHANGE_OF_STOCK as keyof typeof Notification; 780 | } 781 | if (scrapedProduct.discountRate >= THRESHOLD_PERCENTAGE) { 782 | return Notification.THRESHOLD_MET as keyof typeof Notification; 783 | } 784 | 785 | return null; 786 | }; 787 | 788 | export const formatNumber = (num: number = 0) => { 789 | return num.toLocaleString(undefined, { 790 | minimumFractionDigits: 0, 791 | maximumFractionDigits: 0, 792 | }); 793 | }; 794 | ``` 795 | 796 |
797 | 798 | ## 🔗 Links 799 | 800 | Assets used in the project are [here](https://drive.google.com/file/d/1wcV5Jg42CYsF3UMQh771a8eZn1b702iy/view) 801 | 802 | # 803 | --------------------------------------------------------------------------------