├── 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 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/bookmark.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/hand-drawn-arrow.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/red-heart.svg:
--------------------------------------------------------------------------------
1 |
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 |
9 |
--------------------------------------------------------------------------------
/public/assets/icons/arrow-up.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/comment.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/price-tag.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/assets/icons/share.svg:
--------------------------------------------------------------------------------
1 |
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 |
69 | )
70 | }
71 |
72 | export default Searchbar
--------------------------------------------------------------------------------
/public/assets/icons/frame.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/assets/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |

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 |
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 |
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
--------------------------------------------------------------------------------