├── app ├── favicon.ico ├── layout.tsx ├── page.tsx ├── api │ └── cron │ │ └── route.ts ├── globals.css └── products │ └── [id] │ └── page.tsx ├── postcss.config.js ├── next.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 ├── README.md ├── .gitignore ├── components ├── PriceInfoCard.tsx ├── Navbar.tsx ├── ProductCard.tsx ├── Searchbar.tsx └── Modal.tsx ├── tsconfig.json ├── lib ├── mongoose.ts ├── models │ └── product.model.ts ├── scraper │ └── index.ts ├── actions │ └── index.ts ├── utils.ts └── nodemailer │ └── index.ts ├── package.json ├── types └── index.ts └── tailwind.config.ts /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hanzalahwaheed/ProductOwl/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ['m.media-amazon.com'] 5 | } 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /public/assets/icons/x-close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProductOwl 2 | 3 | A web app that helps you track your favourite Amazon products at notifies you when they are at their cheapest! Implemented with the help of Web Scraping! 4 | 5 | ## Features 6 | - Bookmark the product via Email 7 | - Recieve notification emails at a price-drop 8 | 9 | 10 | ## Technologies 11 | NextJS, TailwindCSS, BrightData, Cheerio, Cron-Jobs 12 | 13 | ## Authors 14 | 15 | - [@hanzalahwaheed](https://www.github.com/hanzalahwaheed) 16 | 17 | 18 | -------------------------------------------------------------------------------- /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 | .env 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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const Navbar = () => { 5 | return ( 6 |
7 | 20 |
21 | ); 22 | }; 23 | 24 | export default Navbar; 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | const uri = process.env.MONGODB_URI; 9 | 10 | if (!uri) { 11 | console.log('MONGODB_URI is not defined'); 12 | return; 13 | } 14 | 15 | if (isConnected) { 16 | console.log('=> using existing database connection'); 17 | return; 18 | } 19 | 20 | console.log('Attempting to connect to MongoDB with URI:', uri); 21 | 22 | try { 23 | await mongoose.connect(uri); 24 | 25 | isConnected = true; 26 | console.log('MongoDB Connected'); 27 | } catch (error) { 28 | console.log('Error connecting to MongoDB:', error); 29 | } 30 | } -------------------------------------------------------------------------------- /public/assets/icons/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "productowl", 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": "^2.1.2", 13 | "axios": "^1.5.1", 14 | "cheerio": "^1.0.0-rc.12", 15 | "mongoose": "^7.5.3", 16 | "next": "latest", 17 | "nodemailer": "^6.9.5", 18 | "react": "latest", 19 | "react-dom": "latest", 20 | "react-responsive-carousel": "^3.2.23", 21 | "supports-color": "^8.1.1" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "latest", 25 | "@types/nodemailer": "^6.4.11", 26 | "@types/react": "latest", 27 | "@types/react-dom": "latest", 28 | "autoprefixer": "latest", 29 | "postcss": "latest", 30 | "tailwindcss": "latest", 31 | "typescript": "latest" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from '@/components/Navbar' 2 | import './globals.css' 3 | import type { Metadata } from 'next' 4 | import { Inter, Space_Grotesk } from 'next/font/google' 5 | 6 | const inter = Inter({ subsets: ['latin'] }) 7 | const spaceGrotesk = Space_Grotesk({ 8 | subsets: ['latin'], 9 | weight: ['300', '400', '500', '600', '700'] 10 | }) 11 | 12 | export const metadata: Metadata = { 13 | title: 'ProductOwl', 14 | description: 'Track product prices effortlessly and save money on your online shopping.', 15 | } 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: { 20 | children: React.ReactNode 21 | }) { 22 | return ( 23 | 24 | 25 |
26 | 27 | {children} 28 |
29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /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 | }; 45 | -------------------------------------------------------------------------------- /public/assets/icons/black-heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Searchbar from "@/components/Searchbar"; 2 | import Image from "next/image"; 3 | import { getAllProducts } from "@/lib/actions"; 4 | import ProductCard from "@/components/ProductCard"; 5 | 6 | const Home = async () => { 7 | const allProducts = await getAllProducts(); 8 | 9 | return ( 10 | <> 11 |
12 |
13 |

14 | Track Your Amazon Products with{" "} 15 | ProductOwl 16 |

17 | 18 |
19 |
20 | 21 |
22 |

Searched Products

23 | 24 |
25 | {allProducts?.map((product) => ( 26 | 27 | ))} 28 |
29 |
30 | 31 | ); 32 | }; 33 | 34 | export default Home; 35 | -------------------------------------------------------------------------------- /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 | url: { type: String, required: true, unique: true }, 5 | currency: { type: String, required: true }, 6 | image: { type: String, required: true }, 7 | title: { type: String, required: true }, 8 | currentPrice: { type: Number, required: true }, 9 | originalPrice: { type: Number, required: true }, 10 | priceHistory: [ 11 | { 12 | price: { type: Number, required: true }, 13 | date: { type: Date, default: Date.now } 14 | }, 15 | ], 16 | lowestPrice: { type: Number }, 17 | highestPrice: { type: Number }, 18 | averagePrice: { type: Number }, 19 | discountRate: { type: Number }, 20 | description: { type: String }, 21 | category: { type: String }, 22 | // reviewsCount: { type: Number }, 23 | isOutOfStock: { type: Boolean, default: false }, 24 | users: [ 25 | {email: { type: String, required: true}} 26 | ], default: [], 27 | }, { timestamps: true }); 28 | 29 | const Product = mongoose.models.Product || mongoose.model('Product', productSchema); 30 | 31 | 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: "#BC6C25", 13 | "orange": "#D48D3B", 14 | "green": "#3E9242" 15 | }, 16 | secondary: "#FEFAE0", 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 | }; 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/icons/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/icons/comment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |
53 | setSearchPrompt(e.target.value)} 57 | placeholder="Enter product link" 58 | className="searchbar-input" 59 | /> 60 | 61 | 68 |
69 | ) 70 | } 71 | 72 | export default Searchbar -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/api/cron/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | import { getLowestPrice, getHighestPrice, getAveragePrice, getEmailNotifType } from "@/lib/utils"; 4 | import { connectToDB } from "@/lib/mongoose"; 5 | import Product from "@/lib/models/product.model"; 6 | import { scrapeAmazonProduct } from "@/lib/scraper"; 7 | import { generateEmailBody, sendEmail } from "@/lib/nodemailer"; 8 | 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 | }; 41 | 42 | // Update Products in DB 43 | const updatedProduct = await Product.findOneAndUpdate( 44 | { 45 | url: product.url, 46 | }, 47 | product 48 | ); 49 | 50 | // ======================== 2 CHECK EACH PRODUCT'S STATUS & SEND EMAIL ACCORDINGLY 51 | const emailNotifType = getEmailNotifType( 52 | scrapedProduct, 53 | currentProduct 54 | ); 55 | 56 | if (emailNotifType && updatedProduct.users.length > 0) { 57 | const productInfo = { 58 | title: updatedProduct.title, 59 | url: updatedProduct.url, 60 | }; 61 | // Construct emailContent 62 | const emailContent = await generateEmailBody(productInfo, emailNotifType); 63 | // Get array of user emails 64 | const userEmails = updatedProduct.users.map((user: any) => user.email); 65 | // Send email notification 66 | await sendEmail(emailContent, userEmails); 67 | } 68 | 69 | return updatedProduct; 70 | }) 71 | ); 72 | 73 | return NextResponse.json({ 74 | message: "Ok", 75 | data: updatedProducts, 76 | }); 77 | } catch (error: any) { 78 | throw new Error(`Failed to get all products: ${error.message}`); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /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 = $('#availability span').text().trim().toLowerCase() === 'currently unavailable'; 48 | 49 | const images = 50 | $('#imgBlkFront').attr('data-a-dynamic-image') || 51 | $('#landingImage').attr('data-a-dynamic-image') || 52 | '{}' 53 | 54 | const imageUrls = Object.keys(JSON.parse(images)); 55 | 56 | const currency = extractCurrency($('.a-price-symbol')) 57 | const discountRate = $('.savingsPercentage').text().replace(/[-%]/g, ""); 58 | 59 | const description = extractDescription($) 60 | 61 | // Construct data object with scraped information 62 | const data = { 63 | url, 64 | currency: currency || '$', 65 | image: imageUrls[0], 66 | title, 67 | currentPrice: Number(currentPrice) || Number(originalPrice), 68 | originalPrice: Number(originalPrice) || Number(currentPrice), 69 | priceHistory: [], 70 | discountRate: Number(discountRate), 71 | category: 'category', 72 | stars: 4.5, 73 | isOutOfStock: outOfStock, 74 | description, 75 | lowestPrice: Number(currentPrice) || Number(originalPrice), 76 | highestPrice: Number(originalPrice) || Number(currentPrice), 77 | averagePrice: Number(currentPrice) || Number(originalPrice), 78 | } 79 | 80 | return data; 81 | } catch (error: any) { 82 | console.log(error); 83 | } 84 | } -------------------------------------------------------------------------------- /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 | return newProduct._id; 48 | } catch (error: any) { 49 | throw new Error(`Failed to create/update product: ${error.message}`) 50 | } 51 | } 52 | 53 | export async function getProductById(productId: string) { 54 | try { 55 | connectToDB(); 56 | 57 | const product = await Product.findOne({ _id: productId }); 58 | 59 | if(!product) return null; 60 | 61 | return product; 62 | } catch (error) { 63 | console.log(error); 64 | } 65 | } 66 | 67 | export async function getAllProducts() { 68 | try { 69 | connectToDB(); 70 | 71 | const products = await Product.find().sort({ updatedAt: -1 }); 72 | 73 | return products; 74 | } catch (error) { 75 | console.log(error); 76 | } 77 | } 78 | 79 | export async function getSimilarProducts(productId: string) { 80 | try { 81 | connectToDB(); 82 | 83 | const currentProduct = await Product.findById(productId); 84 | 85 | if(!currentProduct) return null; 86 | 87 | const similarProducts = await Product.find({ 88 | _id: { $ne: productId }, 89 | }).limit(3).sort({ updatedAt: -1 }); 90 | 91 | return similarProducts; 92 | } catch (error) { 93 | console.log(error); 94 | } 95 | } 96 | 97 | export async function addUserEmailToProduct(productId: string, userEmail: string) { 98 | try { 99 | const product = await Product.findById(productId); 100 | 101 | if(!product) return; 102 | 103 | const userExists = product.users.some((user: User) => user.email === userEmail); 104 | 105 | if(!userExists) { 106 | product.users.push({ email: userEmail }); 107 | 108 | await product.save(); 109 | 110 | const emailContent = await generateEmailBody(product, "WELCOME"); 111 | 112 | await sendEmail(emailContent, [userEmail]); 113 | } 114 | } catch (error) { 115 | console.log(error); 116 | } 117 | } -------------------------------------------------------------------------------- /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 | }; 119 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | Description, 4 | Dialog, 5 | DialogBackdrop, 6 | DialogPanel, 7 | } from "@headlessui/react"; 8 | import { FormEvent, useState } from "react"; 9 | import Image from "next/image"; 10 | import { addUserEmailToProduct } from "@/lib/actions"; 11 | 12 | interface Props { 13 | productId: string; 14 | } 15 | 16 | export default function Modal({ productId }: Props) { 17 | let [isOpen, setIsOpen] = useState(false); 18 | const [isSubmitting, setIsSubmitting] = useState(false); 19 | const [email, setEmail] = useState(""); 20 | 21 | const handleSubmit = async (e: FormEvent) => { 22 | e.preventDefault(); 23 | setIsSubmitting(true); 24 | await addUserEmailToProduct(productId, email); 25 | setIsSubmitting(false); 26 | setEmail(""); 27 | closeModal(); 28 | }; 29 | 30 | const openModal = () => setIsOpen(true); 31 | const closeModal = () => setIsOpen(false); 32 | 33 | return ( 34 | <> 35 | 38 | setIsOpen(false)} 41 | className="relative z-50" 42 | > 43 | 47 |
48 | 52 | 53 |
54 |
55 |
56 |
57 | logo 63 |
64 | 65 | close 73 |
74 | 75 |

76 | Stay updated with product pricing alerts right in your 77 | inbox! 78 |

79 | 80 |

81 | Never miss a bargain again with our timely alerts! 82 |

83 |
84 | 85 |
86 | 92 |
93 | mail 99 | 100 | setEmail(e.target.value)} 106 | placeholder="Enter your email address" 107 | className="dialog-input" 108 | /> 109 |
110 | 111 | 114 |
115 |
116 |
117 |
118 |
119 |
120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /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 | const shortenedTitle = 19 | product.title.length > 20 20 | ? `${product.title.substring(0, 20)}...` 21 | : product.title; 22 | 23 | let subject = ""; 24 | let body = ""; 25 | 26 | switch (type) { 27 | case Notification.WELCOME: 28 | subject = `Welcome to Price Tracking for ${shortenedTitle}`; 29 | body = ` 30 |
31 |

Welcome to ProductOwl 🚀

32 |

You are now tracking ${product.title}.

33 |

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

34 |
35 |

${product.title} is back in stock!

36 |

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

37 |

Don't miss out - buy it now!

38 | Product Image 39 |
40 |

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

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

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

50 |

See the product here.

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

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

60 |

Grab the product here now.

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

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

70 |

Grab it right away from here.

71 |
72 | `; 73 | break; 74 | 75 | default: 76 | throw new Error("Invalid notification type."); 77 | } 78 | 79 | return { subject, body }; 80 | } 81 | 82 | const transporter = nodemailer.createTransport({ 83 | host: "smtp-mail.outlook.com", 84 | port: 587, 85 | secure: false, // STARTTLS requires false 86 | auth: { 87 | user: process.env.OUTLOOK_EMAIL, 88 | pass: process.env.OUTLOOK_PASSWORD, 89 | }, 90 | tls: { 91 | ciphers: "SSLv3", // Adjust according to Outlook's TLS requirements if necessary 92 | }, 93 | }); 94 | 95 | // Test the transporter setup 96 | transporter.verify((error, success) => { 97 | if (error) { 98 | console.error("Nodemailer verification error:", error); 99 | } else { 100 | console.log("Nodemailer is ready to send emails"); 101 | } 102 | }); 103 | 104 | export const sendEmail = async ( 105 | emailContent: EmailContent, 106 | sendTo: string[] 107 | ) => { 108 | const mailOptions = { 109 | from: process.env.OUTLOOK_EMAIL, 110 | to: sendTo, 111 | subject: emailContent.subject, 112 | html: emailContent.body, 113 | }; 114 | 115 | try { 116 | console.log("Sending email with the following options:", mailOptions); 117 | const info = await transporter.sendMail(mailOptions); 118 | console.log("Email sent:", info.response); 119 | } catch (error) { 120 | console.error("Error sending email:", error); 121 | throw new Error("Failed to send email."); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /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-[#606C38] 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-[#E9ECEF]; 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 | 73 | .product-info { 74 | @apply flex items-center flex-wrap gap-10 py-6 border-y border-y-[#E4E4E4]; 75 | } 76 | 77 | .product-hearts { 78 | @apply flex items-center gap-2 px-3 py-2 bg-[#FFF0F0] rounded-10; 79 | } 80 | 81 | .product-stars { 82 | @apply flex items-center gap-2 px-3 py-2 bg-[#FBF3EA] rounded-[27px]; 83 | } 84 | 85 | .product-reviews { 86 | @apply flex items-center gap-2 px-3 py-2 bg-white-200 rounded-[27px]; 87 | } 88 | 89 | /* MODAL */ 90 | .dialog-container { 91 | @apply fixed inset-0 z-10 overflow-y-auto bg-black bg-opacity-60; 92 | } 93 | 94 | .dialog-content { 95 | @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; 96 | } 97 | 98 | .dialog-head_text { 99 | @apply text-secondary text-lg leading-[24px] font-semibold mt-4; 100 | } 101 | 102 | .dialog-input_container { 103 | @apply px-5 py-3 mt-3 flex items-center gap-2 border border-gray-300 rounded-[27px]; 104 | } 105 | 106 | .dialog-input { 107 | @apply flex-1 pl-1 border-none text-gray-500 text-base focus:outline-none border border-gray-300 rounded-[27px] shadow-xs; 108 | } 109 | 110 | .dialog-btn { 111 | @apply px-5 py-3 text-white text-base font-semibold border border-secondary bg-secondary rounded-lg mt-8; 112 | } 113 | 114 | /* NAVBAR */ 115 | .nav { 116 | @apply flex justify-center items-center px-6 md:px-20 py-4; 117 | } 118 | 119 | .nav-logo { 120 | @apply font-spaceGrotesk text-[21px] text-secondary font-bold; 121 | } 122 | 123 | /* PRICE INFO */ 124 | .price-info_card { 125 | @apply flex-1 min-w-[200px] flex flex-col gap-2 border-l-[3px] rounded-10 bg-[#FEFAE0] px-5 py-4; 126 | } 127 | 128 | /* PRODUCT CARD */ 129 | .product-card { 130 | @apply sm:w-[292px] sm:max-w-[292px] w-full flex-1 flex flex-col gap-4 rounded-md; 131 | } 132 | 133 | .product-card_img-container { 134 | @apply flex-1 relative flex flex-col gap-5 p-4 rounded-md; 135 | } 136 | 137 | .product-card_img { 138 | @apply max-h-[250px] object-contain w-full h-full bg-[#F8F9FA]; 139 | } 140 | 141 | .product-title { 142 | @apply text-secondary text-xl leading-6 font-semibold truncate; 143 | } 144 | 145 | /* SEARCHBAR INPUT */ 146 | .searchbar-input { 147 | @apply flex-1 min-w-[200px] w-full p-3 rounded-lg shadow-xs text-base text-[#212529] focus:outline-none; 148 | } 149 | 150 | .searchbar-btn { 151 | @apply bg-[#BC6C25] 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; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /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 | 46 | Visit Product 47 | 48 |
49 | 50 |
51 | 52 |
53 |
54 |

55 | {product.currency} {formatNumber(product.currentPrice)} 56 |

57 |

58 | {product.currency} {formatNumber(product.originalPrice)} 59 |

60 |
61 |
62 | 63 |
64 |
65 | 70 | 75 | 80 | 85 |
86 |
87 | 88 | 89 |
90 |
91 | 92 |
93 |
94 |

95 | Product Description 96 |

97 | 98 |
99 | {product?.description?.split('\n')} 100 |
101 |
102 | 103 | 115 |
116 | 117 | {similarProducts && similarProducts?.length > 0 && ( 118 |
119 |

Recently viewed Products

120 | 121 |
122 | {similarProducts.map((product) => ( 123 | 124 | ))} 125 |
126 |
127 | )} 128 |
129 | ) 130 | } 131 | 132 | export default ProductDetails --------------------------------------------------------------------------------