sebee.website🌱
  • Articles
  • CV
  • About
  • Contact

Sebastian Pieczynski's website and blog

Published on: yyyy.mm.dd

Back to Articles
sebee.website
  • Created with ❤️ by Sebastian Pieczyński © 2023-2025.

Links

  • Terms of Use
  • Privacy Policy
  • Cookie Policy
  • ethernal
  • @spieczynski

Web scraper with prisma and typescript part 2 (backend and frontend)

Published on: 11/30/2023

pirate ship filled with gold chests, open chests with jewels and gold coins and gems, a lone dark skinned woman in a dress with a hat on board faces the sea in front, back to the camera, ship in tropical waters, camera from birds eye view on the deck, watercolor

This is the continuation of the part 1 of our project to create a web scraper with local database and typescript.

Contents

  1. Introduction
  2. Recap
  3. Crow's Nest View
  4. Building the backend
    1. Preparing dev environment
    2. Creating the server
    3. API
    4. CORS
  5. Building the frontend
    1. Installing TailwindCSS
    2. Configuring to use Tailwind
    3. Displaying the list of product names
    4. Display products with images and controls
  6. Final implementation
  7. Conclusion
  8. Bonus - Migrating model

Introduction

Arrrr... you ready for boarding?

We have started our journey following the trail left by bright sun and dark moon. Then we have sent messages to our crew and found a map and a key to the treasures hidden on this island. Finally we have unearthed all the gold and precious gems and today we're going to preset them to everyone on board.

Recap

In the first part of the project we created a simple but reliable web scraper. In this part we'll build the backend and the frontend to show our users data we have unearthed from the depths of the web.

We will be using the same repo (it's updated with new features) as in part 1: https://github.com/ethernal/web-scraper-stepped-solution

Every step has a separate branch and main is a finished version.

Crow's Nest View

Keen eyed among you might have noticed that in the model name in schema.prisma file there is a typo. Instead of ScrapedData the model name is ScrappedData. We'll address that issue in the bonus section at the end of the article.

Building the backend

Since Prisma cannot run on frontend we need some way to get the data. We can build an API server to expose a route that will then return the data that we are interested in. Additional benefit is that we can then have some more control over what and how the data is returned.

Preparing dev environment

To make our lives a bit easier while developing a live running process (that requires restarting every time a change is introduced) we can install a 'nodemon' package. Nodemon will automatically restart the server when a change is introduced. It will be helpful if you want to tinker with the implementation later.

Since we want to use tsx to run our .ts files we need to tell nodemon to use tsx when executing .ts files. To do that create a nodemon.json in the root of the project with the map of the executables for specific file extensions.:

then in the package.json:

We'll create the server.ts in just a second.

If you are using ts-node in your project it is not required to create a configuration. nodemon will use it automatically for .ts files.

Creating the server

To be able to receive requests and respond to them we need to create a server that will listen on a specific port and respond to our queries. One of the most popular (read standard) ways to do this is by using express package. We'll also use cors package to allow communication between frontend and backend. Since we are using Typescript we'll also add the types for both packages.

and for develpment:

Now in the root of the project create a server.ts file and paste the code below. We'll go through it line by line in the comments:

API

Our API layer is very simple, it waits for a client to visit the route /api/products and returns all data from the DB based on the parameters passed. The req and res parameters are automatically passed to the function by the Express server.

req stands for request sent by the client browser and res is the response that we will send back to the client according to the paramters passed in req. The req holds all the information about the request:

The server waits for a client (user/browser) to open the browser with URL 'http://localhost:3213/api/products' and when that happens it invokes code in the async (req, res) function.

If the URL we invoked was formed as: http://localhost:5173/?price_lte=80 this is what the request would look like (shortened contents for readability):

As you can see the req object has a lot of potentially useful information about both the request and client sending it. For us the one of most importance is the query object.

In our case that means parsing the query parameters that you have seen in the request body:

Let's break down the maxPrice parsing as it may be a bit complex to look at.

First we check if we can parse the price_lte as an integer. If it's undefined we use empty string ('') and that in turn will produce a NaN (not a number) result. If it is a NaN the we fallback to DEFAULT_MAX_PRICE if not we parse it. The exclamation mark at the end of price_lte is there to tell Typescript that we know what we are doing and price_lte will not be undefined - we already took care of that.

Then we use the rest of the parameters to build a query for the database:

If the sortBy param is not supplied we'll return undefined as orderByQuery that way it will not be included as part of the prisma query, using null here will lead to errors.

Finally the function is invoked and data returned in as the successful (code 200) response.

As you can clearly see this is enough to create a robust functionality and it is quite simple. Of course proper implementation would need to take care of many other things like:

  1. better security
  2. API keys
  3. rate limiting
  4. error handling
  5. error reporting
  6. caching
  7. logging

Our goal here is to just get data so our frontend can render it. But there is one thing we must take care of: CORS.

CORS

CORS stands for Cross-Origin Resource Sharing. MDN docs explain it in details on the CORS documentation page:

Cross-Origin Resource Sharing (CORS) is an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.

For the demo purposes we allow all requests to pass with:

You can try to comment out this line and see what happens when we finish the frontend.

This concludes creating a backend service for our use case. Now let's start using this data and show it to the world.

Code of the finished backend.

Building the frontend

I'll be using TailwindCSS for styling. You can read more about building a robust CSS system in my previous article here.

So we'll install that first and then use it in our frontend.

Installing TailwindCSS

And finally change the tailwind.config.js from:

to:

Configuring to use Tailwind

Now we need to import Tailwind base classes into our frontend.

At the top of the index.css add:

We need to add 100% width to the #root element so that we can arrange our products in a grid.

Now delete App.css from the src folder and remove it from imports.

Displaying the list of product names

Let's start simple and display only the names of the products returned from the backend.

Find the src/App.tsx and replace it's contents with the code below:

Make sure that the API server is running:

And run the Vite app with:

Frontend application should be available at: http://localhost:5173

When it loads names of the pokemon products should be displayed:

Display product names

At this point there are few issues with current implementation:

  1. we cannot control which products are displayed (maximum price),
  2. we cannot control how products are displayed (sorting),

Version displaying product names on GitHub.

So let's add these features now.

Display products with images and controls

Now users of our application have much more flexibility:

second iteration of the app

And as we add more features (more power) we need to take on more responsibilities with it.

Right now when users change the parameters of the search it is not reflected by the link they see. It is important because:

  1. Users can share a link to the search results just with an URL - huge UX win.
  2. Our initial state can be derived from the URL to show what user expects.

Adding such feature is not hard:

Code withURL parameters, images and sorting.

That's a lot of code but we actually only needed to add one line inside fetch function to make it work:

Since fetch happens on every change to the parameters we can set history there and be certain that it's updated whenever new request is made. Grrrreat!

It's not all rum and roses though.

Open the Developer Tools (press F12 inside most browsers to show it) and navigate to the Network tab and reload the page if needed. Now try changing the price..

Requests stream like water into the cannon ball treated hull and whenever we start a new request the old one is still in progress. We need a way to correct that:

  1. To limit the number of requests we can debounce the fetch function.
  2. To cancel the request in flight we can use the AbortController.

Debouncing means that we want to wait a certain amount of time before making a call to a function. It uses a timer that will only fire after a certain amount of time has passed and if a call has been made again it resets the timer to start again. That way it does not call the function with a timeout but runs it once after a certain time of inactivity.

We'll use the useDebounce hook for the functionality.

To debounce the fetch function when price changes by using the useDebounce we'll debounce the maxPrice variable and use the debounced value inside the effect instead.

While we are inside the useEffect we'll also implement the AbortController to cancel the last request whenever we want to start a new one.

Install the usehooks-ts package:

Then in the App.tsx add right after the maxPrice variable:

We have set the debounce timeout to 50ms, feel free to experiment with the value and see when it feels to slow down the UI and when it is not really noticeable.

Finally change the useEffect to use debounced value instead of maxPrice:

You can also see that we have created an AbortController to cancel the last request whenever we fire new one. When we return a function from useEffect it will be called when the component is unmounted (destroyed) by React. So whenever the price changes we cancel the last request (before rendering new page) and create a new one.

We are also intercepting axios response to check if the request was aborted and if so we'll return resolved promise instead of an error. You will see this in development as React fires the effects twice to verify if they run correctly. It is better solution than disabling such behavior as it's meant to make us aware of any issues in our synchronization logic.

With that we have come to an end of our journey. We dug up all the treasures and collected them in our coffers.

In the process we learned how to:

[x] create a simple API server, [x] work with TailwindCSS and build a frontend, [x] use the REST API from the frontend, [x] use useEffect to fetch data, [x] debounce a state variable to limit network requests, [x] abort in-flight requests with AbortController.

See you soon!

Final implementation

Code with debounce and abort controller.

Complete implementation with useDebounce and AbortController:

Conclusion

We have now a fully working web application that can display data we have scraped from the website and it uses our infrastructure. That way we have more control over the data and how it is presented and used ex. it would be fairly easy to implement infinite scroll for the page or we could display similar products when selecting one from the list.

I hope you had fun and learned something new today.

Please feel free to contact me if you have questions or create an issue or PR if you want to add something fun to the project. I'll gladly cover the changes in another article.

Stay safe and know that you are doing great! Even when no one noticed yet.

Bonus - Migrating model

In schema.prisma change model name from ScrappedData to ScrapedData

Then run npx prisma migrate dev, you will see a notification to confirm that db will be reset:

After confirming you will see that migration has been generated and applied:

This will require you to scrape the data again from the website as the old table has been deleted.

Before we can do this though we need to fix the scraper and backed.

In scraper.ts replace call to:

await prisma.scrappedData.upsert with await prisma.scrapedData.upsert

and in server.ts change const data = await prisma.scrappedData.findMany to const data = await prisma.scrapedData.findMany.

Now run npm run scrap (you can change the script name as well if you want 😉).

It will re-populate database with data and download the files again.

And now we are really done.

In this bonus section you have experienced how to migrate the database and what issues arise when models change. This was a destructive change but not all migrations will look like this one. It's still worth keeping in mind that depending on your decisions and what changes you need to make you may be in similar situation.

Code with database migration using prisma.

Back to Articles
1query: { price_lte: '80' },
nodemon.json
1{
2 "execMap": {
3 "ts": "tsx"
4 }
5}
1{
2 "scripts": {
3 "server": "nodemon server.ts",
4 }
5}
1npm install --save-dev nodemon @types/nodemon
1npm install --save express cors
1npm install --save-dev @types/express @types/cors
1npm install -D tailwindcss postcss autoprefixer
2npx tailwindcss init -p
1npm run server
1npm run dev
1npm i usehooks-ts
1Drift detected: Your database schema is not in sync with your migration history.
2
3The following is a summary of the differences between the expected database schema given your migrations files,
4and the actual schema of the database.
5
6It should be understood as the set of changes to get from the expected schema to the actual schema.
7
8If you are running this the first time on an existing database, please make sure to read this documentation page:
9https://www.prisma.io/docs/guides/database/developing-with-prisma-migrate/troubleshooting-development
10
11[+] Added tables
12 - ScrappedData
13 - User
14
15[*] Changed the `ScrappedData` table
16 [+] Added unique index on columns (url)
17
18[*] Changed the `User` table
19 [+] Added unique index on columns (email)
20
21? We need to reset the SQLite database "dev.db" at "file:./data/dev.db"
22Do you want to continue? All data will be lost. » (y/N)
1√ Enter a name for the new migration: ... rename ScrappedData model
2Applying migration `20231128090011_rename_scrapped_data_model`
3
4The following migration(s) have been created and applied from new schema changes:
5
6migrations/
7 └─ 20231128090011_rename_scrapped_data_model/
8 └─ migration.sql
9
10Your database is now in sync with your schema.
11
12✔ Generated Prisma Client (v5.6.0) to .\node_modules\@prisma\client in 86ms
13
14
15Running seed command `tsx prisma/seed.ts` ...
16Seeding the database
17Completed seeding the database
18
19The seed command has been executed.
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5:root {
6 //...
7}
8//...
9
10#root {
11 width:100%;
12}
1/** @type {import('tailwindcss').Config} */
2export default {
3 content: [],
4 theme: {
5 extend: {},
6 },
7 plugins: [],
8}
1/** @type {import('tailwindcss').Config} */
2export default {
3 content: [
4 "./index.html",
5 "./src/**/*.{js,ts,jsx,tsx}",
6 ],
7 theme: {
8 extend: {},
9 },
10 plugins: [],
11}
App.tsx
1import axios from 'axios';
2import { useEffect, useState } from 'react';
3
4type Product = {
5 id: string;
6 url: string;
7 price: number;
8 data: string;
9 dataType: string;
10 createdAt: Date;
11 updatedAt: Date;
12}
13
14function App() {
15 // use search url params to set initial data for the state
16 const searchParams = new URLSearchParams(window.location.search);
17 const priceParam = searchParams.get('price_lte') ?? '';
18 const priceFromURL = isNaN(parseInt(priceParam)) ? 80 : parseInt(priceParam);
19
20 const [products, setProducts] = useState<Product[]>([]);
21 const [maxPrice, setMaxPrice] = useState(priceFromURL);
22
23 useEffect(() => {
24 const controller = new AbortController();
25 const signal = controller.signal;
26
27 const fetchData = async () => {
28 // create a query string based on the parameters passed
29 const price_lte = 'price_lte=' + maxPrice.toString();
30
31 // query will look like ?price_lte=80 by default
32 const queryParams = price_lte;
33
34 const products = (
35 await axios.get(`http://localhost:3213/api/products?${queryParams}`,{signal:signal})).data;
36
37 console.log('products', products);
38 setProducts(products);
39 }
40
41 fetchData();
42 },[maxPrice])
43
44 return (
45 <>
46 {/* Products List */}
47 <div className='w-full grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4 p-8'>
48 {products?.map((data) => {
49
50 const metadata = JSON.parse(data.data);
51 return (
52 <div key={data.url} className='border-slate-300 bg-slate-600 border-2 p-4 flex flex-col gap-2'>
53 <h1 className='text-4xl font-bold self-center'>{metadata.name}</h1>
54 </div>
55 )
56 }
57 )}
58 </div>
59 </>
60 )
61}
62export default App
App.tsx
1import axios from 'axios';
2import { useEffect, useState } from 'react';
3
4type Product = {
5 id: string;
6 url: string;
7 price: number;
8 data: string;
9 dataType: string;
10 createdAt: Date;
11 updatedAt: Date;
12}
13function App() {
14 // use search url params to set initial data for the state
15 const searchParams = new URLSearchParams(window.location.search);
16 const priceParam = searchParams.get('price_lte') ?? '';
17 const priceFromURL = isNaN(parseInt(priceParam)) ? 80 : parseInt(priceParam);
18
19 const [products, setProducts] = useState<Product[]>([]);
20 const [sortOptions, setSortOptions] = useState({sortBy: searchParams.get('sortBy') || '', sortOrder: searchParams.get('sortOrder') || 'asc'});
21 const [maxPrice, setMaxPrice] = useState(priceFromURL);
22
23 useEffect(() => {
24 const fetchData = async () => {
25 // create a query string based on the parameters passed
26 // if any of the parameters is empty then don't add it to the query string
27 const price_lte = 'price_lte=' + maxPrice.toString();
28 const sortBy = sortOptions.sortBy !== '' ? '&sortBy=' + sortOptions.sortBy : '';
29 const sortOrder = sortOptions.sortBy !== '' && sortOptions.sortOrder !== '' ? '&sortOrder=' + sortOptions.sortOrder : '';
30
31 const queryParams = price_lte + sortBy + sortOrder;
32
33
34 const products = (
35 await axios.get(`http://localhost:3213/api/products?${queryParams}`)).data;
36
37 console.log('products', products);
38 setProducts(products);
39 }
40
41 fetchData();
42
43 },[maxPrice, sortOptions.sortBy, sortOptions.sortOrder])
44
45
46 const handleSortByChange = () => {
47
48 setSortOptions({...sortOptions, sortBy: sortOptions.sortBy === 'price' ? '' : 'price'})
49 }
50
51 const handleSortOrderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
52 setSortOptions({...sortOptions, sortOrder: e.target.value});
53 }
54
55 return (
56 <>
57 <div className='py-8 sticky top-0 bg-slate-800 opacity-95'>
58 <div className='flex justify-center items-baseline gap-2'>
59
60 <label className='text-2xl font-bold' htmlFor='max-price'>Max Price: </label>
61 <input
62 id='max-price'
63 type="range"
64 min={25}
65 max={200}
66 value={maxPrice}
67 onChange={(e) => setMaxPrice(parseInt(e.target.value))}
68 className="w-1/2"
69 />
70 <p className='text-2xl font-bold'>{maxPrice}</p>
71 </div>
72 <div className='flex justify-center items-baseline gap-2 mb-4 text-2xl'>
73 <label htmlFor='sort'>Sort (by price)</label>
74 <input type='checkbox'
75 className='text-2xl font-bold me-4 w-6 h-6' id="sort"
76 name="sort"
77 checked={sortOptions.sortBy === 'price' ? true : false}
78 aria-checked={sortOptions.sortBy === 'price' ? true : false}
79
80 onChange={handleSortByChange}
81 />
82 {/* dropdown to select asc or desc sort order for the query param */}
83 <select name="sortOrder" id="sortOrder" value={sortOptions.sortOrder} onChange={handleSortOrderChange}>
84 <option value="asc">Lowest first</option>
85 <option value="desc">Highest first</option>
86 </select>
87
88 </div>
89 <div className='flex justify-center items-baseline gap-2'>
90 <p className='text-2xl font-bold'>Total Products: {products?.length}</p>
91 </div>
92 </div>
93
94 {/* Products List */}
95 <div className='w-full grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4 p-8'>
96 {products?.map((data) => {
97
98 const metadata = JSON.parse(data.data);
99 return (
100 <div key={data.url} className='border-slate-300 bg-slate-600 border-2 p-4 flex flex-col gap-2'>
101 <h1 className='text-4xl font-bold self-center'>{metadata.name}</h1>
102 <img src={metadata.image} alt='' />
103 <p className='bg-slate-300 text-black p-2 inline-block rounded-lg self-start font-bold text-xl tracking-wide'>{metadata.currency}{data.price}</p>
104 </div>
105 )
106 }
107 )}
108
109 </div>
110 </>
111 )
112}
113
114export default App
App.tsx
1import axios from 'axios';
2import { useEffect, useState } from 'react';
3
4type Product = {
5 id: string;
6 url: string;
7 price: number;
8 data: string;
9 dataType: string;
10 createdAt: Date;
11 updatedAt: Date;
12}
13function App() {
14 // use search url params to set initial data for the state
15 const searchParams = new URLSearchParams(window.location.search);
16 const priceParam = searchParams.get('price_lte') ?? '';
17 const priceFromURL = isNaN(parseInt(priceParam)) ? 80 : parseInt(priceParam);
18
19 const [products, setProducts] = useState<Product[]>([]);
20 const [sortOptions, setSortOptions] = useState({sortBy: searchParams.get('sortBy') || '', sortOrder: searchParams.get('sortOrder') || 'asc'});
21 const [maxPrice, setMaxPrice] = useState(priceFromURL);
22
23 useEffect(() => {
24 const fetchData = async () => {
25 // create a query string based on the parameters passed
26 // if any of the parameters is empty then don't add it to the query string
27 const price_lte = 'price_lte=' + maxPrice.toString();
28 const sortBy = sortOptions.sortBy !== '' ? '&sortBy=' + sortOptions.sortBy : '';
29 const sortOrder = sortOptions.sortBy !== '' && sortOptions.sortOrder !== '' ? '&sortOrder=' + sortOptions.sortOrder : '';
30
31 // query will look like ?price_lte=80&sortBy=price&sortOrder=asc with all parameters
32 // if sortBy is missing it and the sortOrder will not be added,
33 // adding sortOrder alone will trigger an error with Prisma
34 const queryParams = price_lte + sortBy + sortOrder;
35
36 // these parameters should be part of the QueryParams on the frontend as well that way client can share the link
37 // with current state of the application: use URLSearchParams
38 history.pushState(queryParams, '', `?${queryParams}`);
39
40 const products = (await axios.get(`http://localhost:3213/api/products?${queryParams}`)).data;
41
42 console.log('products', products);
43 setProducts(products);
44 }
45
46 fetchData();
47 },[maxPrice, sortOptions.sortBy, sortOptions.sortOrder])
48
49
50 const handleSortByChange = () => {
51 setSortOptions({...sortOptions, sortBy: sortOptions.sortBy === 'price' ? '' : 'price'})
52 }
53
54 const handleSortOrderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
55 setSortOptions({...sortOptions, sortOrder: e.target.value});
56 }
57
58 return (
59 <>
60 <div className='py-8 sticky top-0 bg-slate-800 opacity-95'>
61 <div className='flex justify-center items-baseline gap-2'>
62 <label className='text-2xl font-bold' htmlFor='max-price'>Max Price: </label>
63 <input
64 id='max-price'
65 type="range"
66 min={25}
67 max={200}
68 value={maxPrice}
69 onChange={(e) => setMaxPrice(parseInt(e.target.value))}
70 className="w-1/2"
71 />
72 <p className='text-2xl font-bold'>{maxPrice}</p>
73 </div>
74 <div className='flex justify-center items-baseline gap-2 mb-4 text-2xl'>
75 <label htmlFor='sort'>Sort (by price)</label>
76 <input type='checkbox'
77 className='text-2xl font-bold me-4 w-6 h-6' id="sort"
78 name="sort"
79 checked={sortOptions.sortBy === 'price' ? true : false}
80 aria-checked={sortOptions.sortBy === 'price' ? true : false}
81
82 onChange={handleSortByChange}
83 />
84 {/* dropdown to select asc or desc sort order for the query param */}
85 <select name="sortOrder" id="sortOrder" value={sortOptions.sortOrder} onChange={handleSortOrderChange}>
86 <option value="asc">Lowest first</option>
87 <option value="desc">Highest first</option>
88 </select>
89
90 </div>
91 <div className='flex justify-center items-baseline gap-2'>
92 <p className='text-2xl font-bold'>Total Products: {products?.length}</p>
93 </div>
94 </div>
95
96 {/* Products List */}
97 <div className='w-full grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4 p-8'>
98 {products?.map((data) => {
99
100 const metadata = JSON.parse(data.data);
101 return (
102 <div key={data.url} className='border-slate-300 bg-slate-600 border-2 p-4 flex flex-col gap-2'>
103 <h1 className='text-4xl font-bold self-center'>{metadata.name}</h1>
104 <img src={metadata.image} alt={metadata.name} />
105 <p className='bg-slate-300 text-black p-2 inline-block rounded-lg self-start font-bold text-xl tracking-wide'>{metadata.currency}{data.price}</p>
106 </div>
107 )
108 }
109 )}
110
111 </div>
112 </>
113 )
114}
115
116export default App
App.tsx
1history.pushState(queryParams, '', `?${queryParams}`);
App.tsx
1import { useDebounce } from 'usehooks-ts';
2
3//...
4
5const [maxPrice, setMaxPrice] = useState(priceFromURL);
6const debouncedMaxPrice = useDebounce(maxPrice, 50);
App.tsx
1useEffect(() => {
2 const controller = new AbortController();
3 const signal = controller.signal;
4
5 const fetchData = async () => {
6 const price_lte = 'price_lte=' + debouncedMaxPrice.toString();
7 //...
8 }
9 //...
10 fetchData();
11
12 return () => {
13 console.log('Aborting fetch..')
14 controller.abort();
15 }
16},[debouncedMaxPrice, sortOptions.sortBy, sortOptions.sortOrder]);
App.tsx
1import axios from 'axios';
2import { useEffect, useState } from 'react';
3import { useDebounce } from 'usehooks-ts';
4
5type Product = {
6 id: string;
7 url: string;
8 price: number;
9 data: string;
10 dataType: string;
11 createdAt: Date;
12 updatedAt: Date;
13}
14function App() {
15 // use search url params to set initial data for the state
16 const searchParams = new URLSearchParams(window.location.search);
17 const priceParam = searchParams.get('price_lte') ?? '';
18 const priceFromURL = isNaN(parseInt(priceParam)) ? 80 : parseInt(priceParam);
19
20 const [products, setProducts] = useState<Product[]>([]);
21 const [sortOptions, setSortOptions] = useState({sortBy: searchParams.get('sortBy') || '', sortOrder: searchParams.get('sortOrder') || 'asc'});
22 const [maxPrice, setMaxPrice] = useState(priceFromURL);
23 const debouncedMaxPrice = useDebounce(maxPrice, 50);
24
25 useEffect(() => {
26 const controller = new AbortController();
27 const signal = controller.signal;
28
29 const fetchData = async () => {
30 // create a query string based on the parameters passed
31 // if any of the parameters is empty then don't add it to the query string
32 const price_lte = 'price_lte=' + debouncedMaxPrice.toString();
33 const sortBy = sortOptions.sortBy !== '' ? '&sortBy=' + sortOptions.sortBy : '';
34 const sortOrder = sortOptions.sortBy !== '' && sortOptions.sortOrder !== '' ? '&sortOrder=' + sortOptions.sortOrder : '';
35
36 // query will look like ?price_lte=80&sortBy=price&sortOrder=asc with all parameters
37 // if sortBy is missing it and the sortOrder will not be added,
38 // adding sortOrder alone will trigger an error with Prisma
39 const queryParams = price_lte + sortBy + sortOrder;
40
41 // these parameters should be part of the QueryParams on the frontend as well that way client can share the link
42 // with current state of the application: use URLSearchParams
43 history.pushState(queryParams, '', `?${queryParams}`);
44
45 // the code below will prevent axios from throwing promise error (CanceledError) in the console when fetch is cancelled.
46 // see answer by [Scott McAlister](https://stackoverflow.com/users/8288828/scott-mcallister) on [StackOverflow]() https://stackoverflow.com/questions/73140563/axios-throwing-cancelederror-with-abort-controller-in-react)
47 axios.interceptors.response.use(
48 (response) => response,
49 (error) => {
50 if (error.code === "ERR_CANCELED") {
51 // aborted in useEffect cleanup
52 return Promise.resolve({status: 499})
53 }
54 return Promise.reject((error.response && error.response.data) || 'Error')
55 }
56 );
57
58 const products = (
59 await axios.get(`http://localhost:3213/api/products?${queryParams}`,{signal:signal})).data;
60
61 console.log('products', products);
62 setProducts(products);
63 }
64
65 fetchData();
66
67 return () => {
68 console.log('Aborting fetch..')
69 controller.abort();
70 }
71 },[debouncedMaxPrice, sortOptions.sortBy, sortOptions.sortOrder])
72
73
74 const handleSortByChange = () => {
75
76 setSortOptions({...sortOptions, sortBy: sortOptions.sortBy === 'price' ? '' : 'price'})
77 }
78
79 const handleSortOrderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
80 setSortOptions({...sortOptions, sortOrder: e.target.value});
81 }
82
83 return (
84 <>
85 <div className='py-8 sticky top-0 bg-slate-800 opacity-95'>
86 <div className='flex justify-center items-baseline gap-2'>
87
88 <label className='text-2xl font-bold' htmlFor='max-price'>Max Price: </label>
89 <input
90 id='max-price'
91 type="range"
92 min={25}
93 max={200}
94 value={maxPrice}
95 onChange={(e) => setMaxPrice(parseInt(e.target.value))}
96 className="w-1/2"
97 />
98 <p className='text-2xl font-bold'>{maxPrice}</p>
99 </div>
100 <div className='flex justify-center items-baseline gap-2 mb-4 text-2xl'>
101 <label htmlFor='sort'>Sort (by price)</label>
102 <input type='checkbox'
103 className='text-2xl font-bold me-4 w-6 h-6' id="sort"
104 name="sort"
105 checked={sortOptions.sortBy === 'price' ? true : false}
106 aria-checked={sortOptions.sortBy === 'price' ? true : false}
107
108 onChange={handleSortByChange}
109 />
110 {/* dropdown to select asc or desc sort order for the query param */}
111 <select name="sortOrder" id="sortOrder" value={sortOptions.sortOrder} onChange={handleSortOrderChange}>
112 <option value="asc">Lowest first</option>
113 <option value="desc">Highest first</option>
114 </select>
115
116 </div>
117 <div className='flex justify-center items-baseline gap-2'>
118 <p className='text-2xl font-bold'>Total Products: {products?.length}</p>
119 </div>
120 </div>
121
122 {/* Products List */}
123 <div className='w-full grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4 p-8'>
124 {products?.map((data) => {
125
126 const metadata = JSON.parse(data.data);
127 return (
128 <div key={data.url} className='border-slate-300 bg-slate-600 border-2 p-4 flex flex-col gap-2'>
129 <h1 className='text-4xl font-bold self-center'>{metadata.name}</h1>
130 <img src={metadata.image} alt={metadata.name} />
131 <p className='bg-slate-300 text-black p-2 inline-block rounded-lg self-start font-bold text-xl tracking-wide'>{metadata.currency}{data.price}</p>
132 </div>
133 )
134 }
135 )}
136
137 </div>
138 </>
139 )
140}
141
142export default App
1// import required modules for server and connection between front and backend
2import cors from 'cors';
3import express from 'express';
4
5// import database library
6import { prisma } from './src/lib/prisma';
7
8type RequestParams = {
9 price_lte?: string
10 sortBy?: string
11 sortOrder?: string
12}
13
14const DEFAULT_MAX_PRICE = 100;
15// server port setup
16const PORT = process.env.PORT || 3213;
17
18// create an instance of an Express server
19const app = express();
20
21// apply cors middleware with no settings (allows all connections)
22app.use(cors());
23
24// create a route that will respond with all data that we have in a DB
25// under http://localhost:3213/api/products
26app.get('/api/products', async (req, res) => {
27
28 const {price_lte, sortBy = '', sortOrder = 'asc'} = req.query as RequestParams;
29 // query parameters from request since it's all passed as strings we need to parse data as well
30 const maxPrice = isNaN(parseInt(price_lte ?? '')) ? DEFAULT_MAX_PRICE : parseInt(price_lte!); // set default value if not given (never trust data from the frontend)
31
32 // build part of the query based on the parameters passed
33 const orderByQuery = sortBy !== '' ? {
34 orderBy: {
35 [sortBy]: sortOrder
36 }
37 } : undefined; // return undefined so that the query is empty and ignored when destructuring below
38
39 // declare a function that gets all data from the DB
40 const fetchData = async () => {
41
42 const data = await prisma.scrappedData.findMany({
43 where: {
44 price: {
45 lte: maxPrice
46 }
47 },
48 ...orderByQuery
49 });
50 return data;
51 }
52
53 return res.status(200).json(await fetchData()); // return all data with success status
54})
55
56// start the server on set port and display message in the console
57app.listen(PORT, () => {
58 console.log(`Server Listening on: http://localhost:${PORT}`);
59});
server.ts
1app.get('/api/products', async (req, res) => {
2 //...
3});
1Request: <ref *2> IncomingMessage {
2 //...
3 httpVersionMajor: 1,
4 httpVersionMinor: 1,
5 httpVersion: '1.1',
6 complete: false,
7 rawHeaders: [
8 'Host',
9 'localhost:3213',
10 'User-Agent',
11 'Accept',
12 'application/json, text/plain, */*',
13 'Accept-Language',
14 'Accept-Encoding',
15 'gzip, deflate, br',
16 'Origin',
17 'http://localhost:5173',
18 'DNT',
19 '1',
20 'Connection',
21 'keep-alive',
22 'Referer',
23 'http://localhost:5173/',
24 'Sec-Fetch-Dest',
25 'empty',
26 'Sec-Fetch-Mode',
27 'cors',
28 'Sec-Fetch-Site',
29 'same-site'
30 ],
31 url: '/api/products?price_lte=80',
32 method: 'GET',
33 statusCode: null,
34 statusMessage: null,
35 //...
36 },
37 //...
38 originalUrl: '/api/products?price_lte=80',
39 _parsedUrl: Url {
40 protocol: null,
41 slashes: null,
42 auth: null,
43 host: null,
44 port: null,
45 hostname: null,
46 hash: null,
47 search: '?price_lte=80',
48 query: 'price_lte=80',
49 pathname: '/api/products',
50 path: '/api/products?price_lte=80',
51 href: '/api/products?price_lte=80',
52 _raw: '/api/products?price_lte=80'
53 },
54 params: {},
55 query: { price_lte: '80' },
56 //...
57 route: Route {
58 path: '/api/products',
59 stack: [ [Layer] ],
60 methods: { get: true }
61 },
62 [Symbol(kCapture)]: false,
63 [Symbol(kHeaders)]: {
64 host: 'localhost:3213',
65 accept: 'application/json, text/plain, */*',
66 'accept-language': 'pl,en-US;q=0.7,en;q=0.3',
67 'accept-encoding': 'gzip, deflate, br',
68 origin: 'http://localhost:5173',
69 dnt: '1',
70 connection: 'keep-alive',
71 referer: 'http://localhost:5173/',
72 'sec-fetch-dest': 'empty',
73 'sec-fetch-mode': 'cors',
74 'sec-fetch-site': 'same-site'
75 },
76//...
server.ts
1// destructure with default values
2const {price_lte, sortBy = '', sortOrder = 'asc'} = req.query as RequestParams;
3// parse max price and set it, in case of errors fallback to default value
4const maxPrice = isNaN(parseInt(price_lte ?? '')) ? DEFAULT_MAX_PRICE : parseInt(price_lte!);
1const maxPrice = isNaN(parseInt(price_lte ?? '')) ? DEFAULT_MAX_PRICE : parseInt(price_lte!);
server.ts
1const orderByQuery = sortBy !== '' ? {
2 orderBy: {
3 [sortBy]: sortOrder
4 }
5 } : undefined; // return undefined so that the query is empty and ignored when destructuring below, anything else will result in query error
6
7
8 const fetchData = async () => {
9 const data = await prisma.scrappedData.findMany({where:{
10 price: {
11 lte: maxPrice
12 }
13 },
14 ...orderByQuery
15
16 });
17 return data;
18 }
server.ts
1return res.status(200).json(await fetchData());
server.ts
1import cors from 'cors';
2
3//...
4
5const app = express();
6app.use(cors()); // no options indicate all connections are allowed
1// see answer by [Scott McAlister](https://stackoverflow.com/users/8288828/scott-mcallister) on [StackOverflow]() https://stackoverflow.com/questions/73140563/axios-throwing-cancelederror-with-abort-controller-in-react)
2axios.interceptors.response.use(
3 (response) => response,
4 (error) => {
5 if (error.code === "ERR_CANCELED") {
6 // aborted in useEffect cleanup
7 return Promise.resolve({status: 499})
8 }
9 return Promise.reject((error.response && error.response.data) || 'Error')
10 }
11);