├── 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 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
--------------------------------------------------------------------------------
/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 | .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 |
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 |
16 |
17 |
{value}
18 |
19 |
20 | );
21 | };
22 |
23 | export default PriceInfoCard
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
4 |
--------------------------------------------------------------------------------
/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 | {
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 |
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: "#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 |
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 |
35 | ))}
36 |
37 |
38 |
45 |
46 | )
47 | }
48 |
49 | export default HeroCarousel
--------------------------------------------------------------------------------
/public/assets/icons/star.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/comment.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
65 | );
66 | };
67 |
68 | export default Searchbar
--------------------------------------------------------------------------------
/public/assets/icons/price-tag.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/assets/icons/share.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/assets/icons/frame.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/public/assets/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
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 |

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 |
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 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {product.title}
40 |
41 |
42 |
47 | Visit Product
48 |
49 |
50 |
51 |
52 |
53 |
59 |
60 |
61 | {product.reviewsCount}
62 |
63 |
64 |
65 |
66 |
72 |
73 |
74 |
75 |
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 |
104 |
105 | {product.stars || "25"}
106 |
107 |
108 |
109 |
110 |
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 |
5 |
6 |
7 |
8 |
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 |

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