(null)
85 | const router = useRouter()
86 |
87 | useEffect(() => {
88 | setPagination(null)
89 | const query = createQuery(filterState)
90 | router.push(router.pathname, query)
91 | }, [filterState])
92 |
93 | useEffect(() => {
94 | if (!pagination) return
95 | const query = createQuery({ ...filterState, page: pagination })
96 | router.push(router.pathname, query)
97 | }, [pagination])
98 |
99 | return (
100 |
101 | {children}
102 |
103 | )
104 | }
105 |
106 | export const useFilterContext = () => {
107 | const context = useContext(FilterContext)
108 | if (context === undefined) {
109 | throw new Error('useFilterContext must be used within a FilterProvider')
110 | }
111 | return context
112 | }
113 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master
6 | [travis-url]: https://travis-ci.org/nestjs/nest
7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux
8 | [linux-url]: https://travis-ci.org/nestjs/nest
9 |
10 | A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
26 |
27 | ## Description
28 |
29 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
30 |
31 | ## Installation
32 |
33 | ```bash
34 | $ npm install
35 | ```
36 |
37 | ## Running the app
38 |
39 | ```bash
40 | # development
41 | $ npm run start
42 |
43 | # watch mode
44 | $ npm run start:dev
45 |
46 | # production mode
47 | $ npm run start:prod
48 | ```
49 |
50 | ## Test
51 |
52 | ```bash
53 | # unit tests
54 | $ npm run test
55 |
56 | # e2e tests
57 | $ npm run test:e2e
58 |
59 | # test coverage
60 | $ npm run test:cov
61 | ```
62 |
63 | ## Support
64 |
65 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
66 |
67 | ## Stay in touch
68 |
69 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
70 | - Website - [https://nestjs.com](https://nestjs.com/)
71 | - Twitter - [@nestframework](https://twitter.com/nestframework)
72 |
73 | ## License
74 |
75 | Nest is [MIT licensed](LICENSE).
76 |
--------------------------------------------------------------------------------
/frontend/src/components/Card/CardList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import Card from './Card'
4 | import styles from './CardList.module.css'
5 | import { useQuery } from 'react-query'
6 | import NotFoundIcon from '../Icons/NotFoundIcon'
7 | import { AnimatePresence, motion } from 'framer-motion'
8 | import { useRouter } from 'next/router'
9 | import CardSkeleton from './CardSkeleton'
10 |
11 | import { useUserContext } from 'src/context/UserContext/UserContext'
12 | import { BASE_URL } from 'src/utils/api'
13 | import { useFilterContext } from 'src/context/FilterContext/FilterContext'
14 |
15 | const CardList: React.FC = () => {
16 | const router = useRouter()
17 | const { pagination, setPagination } = useFilterContext()
18 | const { userState, addOneToCart, removeOneFromCart } = useUserContext()
19 |
20 | const { isLoading, error, data, refetch, isFetching } = useQuery('productsData', () =>
21 | fetch(`${BASE_URL}/product${router.asPath}`).then(res => res.json()),
22 | )
23 |
24 | useEffect(() => {
25 | if (router.route !== '/') return
26 | refetch()
27 | }, [router.query])
28 |
29 | if (error) return 'An error has occurred: ' + error.message
30 | if (data && !data.products?.length) return
31 |
32 | const checkIsInCart = (id: string) => {
33 | if (userState) {
34 | if (userState.shoppingCart.includes(id)) return true
35 | } else {
36 | if (window.localStorage.getItem('cart')?.includes(id + ',')) return true
37 | }
38 |
39 | return false
40 | }
41 |
42 | const changePage = (page: number) => {
43 | setPagination(page)
44 | window.scrollTo({
45 | top: 0,
46 | behavior: 'smooth',
47 | })
48 | }
49 |
50 | return (
51 | <>
52 |
53 | {isLoading || isFetching ? (
54 | Array.from({ length: 10 }).map((e, i) => {
55 | return
56 | })
57 | ) : (
58 |
59 | {data.products.map((e: any, i: number) => {
60 | return (
61 |
62 | 5 ? 'lazy' : 'eager'}
68 | addOneToCart={addOneToCart}
69 | removeOneFromCart={removeOneFromCart}
70 | isInCart={checkIsInCart(e._id)}
71 | >
72 |
73 | )
74 | })}
75 |
76 | )}
77 |
78 |
79 | {Array.from({ length: data?.numberOfPages }).map((e, i) => {
80 | const pageNumber = i + 1
81 |
82 | let buttonStyle = styles.button_not_selected
83 | if ((!pagination && pageNumber === 1) || pageNumber === pagination) buttonStyle = styles.button_selected
84 |
85 | return (
86 |
94 | )
95 | })}
96 |
97 | >
98 | )
99 | }
100 |
101 | export default CardList
102 |
--------------------------------------------------------------------------------
/frontend/pages/cart/index.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: grid;
3 | grid-template-columns: 1fr 225px;
4 | grid-template-rows: 1fr;
5 | gap: 0 2rem;
6 | grid-template-areas: 'list summary';
7 | padding: 1rem 1rem;
8 | }
9 |
10 | .cart_list_section {
11 | grid-area: list;
12 | }
13 |
14 | .summary {
15 | grid-area: summary;
16 | display: flex;
17 | flex-direction: column;
18 | justify-content: flex-start;
19 | align-items: flex-end;
20 | padding: 1.5rem 1rem;
21 | background: var(--c-bg-secondary);
22 | border-radius: 8px;
23 | box-shadow: 0 1px 2px var(--shadow);
24 | height: max-content;
25 | }
26 |
27 | .summary_title {
28 | font-size: 1.25rem;
29 | font-weight: 700;
30 | color: var(--c-primary);
31 | margin-bottom: 0.25rem;
32 | }
33 | .summary_cart_length {
34 | margin-bottom: 2rem;
35 | }
36 | .summary_cart_total_label {
37 | margin-bottom: 0.25rem;
38 | }
39 | .summary_cart_total {
40 | font-weight: 700;
41 | font-size: 1.75rem;
42 | }
43 | .summary_submit_button {
44 | cursor: pointer;
45 | white-space: nowrap;
46 | text-transform: uppercase;
47 | font-weight: 700;
48 | color: var(--c-text-on-p);
49 | background: var(--c-primary-light);
50 | padding: 0.75rem 0.75rem;
51 | margin-top: 2rem;
52 | border-radius: 4px;
53 | }
54 |
55 | .product_list {
56 | display: flex;
57 | flex-direction: column;
58 | list-style-type: none;
59 | }
60 |
61 | .product_list_item {
62 | position: relative;
63 | background: var(--c-bg-secondary);
64 | border-radius: 8px;
65 | box-shadow: 0 1px 2px var(--shadow);
66 | margin-bottom: 2rem;
67 | & > a {
68 | min-height: 100px;
69 | display: flex;
70 | flex-direction: row;
71 | justify-content: flex-start;
72 | align-items: flex-start;
73 | padding-left: 1rem;
74 | }
75 | .image {
76 | margin: auto 0;
77 | min-width: 100px;
78 | max-width: 100px;
79 | & > img {
80 | min-width: 100%;
81 | max-width: 100%;
82 | }
83 | }
84 | .name {
85 | margin: 0 2rem;
86 | line-height: 1.25;
87 | font-weight: 700;
88 | margin: auto 2rem;
89 | }
90 | .price {
91 | padding: 0.25rem;
92 | padding-left: 1rem;
93 | background: var(--c-primary);
94 | color: var(--c-text-on-p);
95 |
96 | margin-top: 1rem;
97 | text-align: right;
98 | font-weight: 700;
99 | font-size: 1.25rem;
100 |
101 | border-top-left-radius: 1.25rem;
102 | border-bottom-left-radius: 1.25rem;
103 | }
104 | .trash_icon {
105 | cursor: pointer;
106 | position: absolute;
107 | bottom: 6px;
108 | right: 6px;
109 |
110 | & > svg {
111 | fill: #c53030;
112 | }
113 | transition: transform 300ms cubic-bezier(0.25, 0.8, 0.25, 1);
114 | &:hover,
115 | &:focus {
116 | transform: rotate(-25deg);
117 | }
118 | }
119 |
120 | transition: box-shadow 300ms cubic-bezier(0.25, 0.8, 0.25, 1);
121 | &:hover,
122 | &:focus {
123 | box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
124 | }
125 | }
126 |
127 | .modal_content {
128 | display: flex;
129 | justify-content: space-evenly;
130 | align-items: center;
131 | width: 100%;
132 | }
133 | .modal_icon {
134 | color: var(--green500);
135 | }
136 | .modal_message {
137 | font-size: 2rem;
138 | }
139 |
140 | @media (max-width: 900px) {
141 | .summary {
142 | position: fixed;
143 | bottom: 0;
144 | left: 0;
145 | z-index: 5;
146 | justify-content: center;
147 | align-items: center;
148 | width: 100%;
149 | padding: 1rem 0;
150 | border-top-left-radius: 36px;
151 | border-top-right-radius: 36px;
152 | background: #ffffff;
153 | box-shadow: -2px -9px 14px -5px rgba(0, 0, 0, 0.61);
154 | }
155 | .container {
156 | display: flex;
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/frontend/src/components/Form/AuthForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useForm } from 'react-hook-form'
3 |
4 | import styles from './AuthForm.module.css'
5 | import { API_Login, API_Signup, API_GetUser, API_MergeLocalStorageCartWithDatabase } from 'src/utils/api'
6 | import { AuthDto } from 'src/utils/auth-form.dto'
7 | import Button from '../Button/Button'
8 | import { useRouter } from 'next/router'
9 | import Spinner from '../Spinner/Spinner'
10 | import { useUserContext } from 'src/context/UserContext/UserContext'
11 |
12 | interface FormData {
13 | email: string
14 | password: string
15 | name: string
16 | }
17 | interface Props {
18 | activePage: 'Log in' | 'Sign up'
19 | }
20 |
21 | const AuthForm = ({ activePage }: Props) => {
22 | const router = useRouter()
23 | const { setAccessToken, dispatchUserState } = useUserContext()
24 | const [showPassword, setShowPassword] = useState(false)
25 | const [serverError, setServerError] = useState(null)
26 | const { register, unregister, handleSubmit, errors } = useForm({
27 | defaultValues: { email: '', password: '' },
28 | })
29 | const [submitting, setSubmitting] = useState(false)
30 |
31 | const submitHandler = async (formData: FormData) => {
32 | setSubmitting(true)
33 |
34 | const result =
35 | activePage === 'Log in'
36 | ? await API_Login(formData.email, formData.password)
37 | : await API_Signup(formData.name, formData.email, formData.password)
38 |
39 | if (result.error) {
40 | setServerError(result.error)
41 | } else {
42 | setServerError(null)
43 | await API_MergeLocalStorageCartWithDatabase(result.accessToken)
44 | const user = await API_GetUser(result.accessToken)
45 | if (user) {
46 | setAccessToken(result.accessToken)
47 | dispatchUserState({
48 | type: 'save',
49 | payload: user,
50 | })
51 | router.push('/')
52 | }
53 | }
54 |
55 | setSubmitting(false)
56 | }
57 | useEffect(() => {
58 | unregister(['email', 'password'])
59 | register()
60 | setServerError(null)
61 | }, [activePage])
62 |
63 | return (
64 |
107 | )
108 | }
109 |
110 | export default AuthForm
111 |
--------------------------------------------------------------------------------
/frontend/pages/cart/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useState } from 'react'
2 | import styles from './index.module.css'
3 | import Link from 'next/link'
4 | import { ProductPreview } from 'src/context/UserContext/interfaces'
5 | import TrashIcon from 'src/components/Icons/TrashIcon'
6 | import { useUserContext } from 'src/context/UserContext/UserContext'
7 | import { API_GetProducts } from 'src/utils/api'
8 | import Modal from 'src/components/Modal'
9 | import CheckIcon from 'src/components/Icons/CheckIcon'
10 | import { useRouter } from 'next/router'
11 |
12 | const index = () => {
13 | const router = useRouter()
14 | const [products, setProducts] = useState([])
15 | const { userState, removeOneFromCart, removeAllFromCart } = useUserContext()
16 | const [modalIsOpen, setModalIsOpen] = useState(false)
17 |
18 | useEffect(() => {
19 | getProducts()
20 | }, [userState?.shoppingCart])
21 |
22 | const deleteHandler = async (id: string) => {
23 | await removeOneFromCart(id)
24 | getProducts()
25 | }
26 | async function getProducts() {
27 | let cartArray = null
28 |
29 | if (userState) {
30 | cartArray = userState.shoppingCart.toString()
31 | } else {
32 | cartArray = window.localStorage.getItem('cart')?.slice(0, -1)
33 | }
34 |
35 | if (!cartArray) {
36 | setProducts([])
37 | return
38 | }
39 |
40 | const data = await API_GetProducts(cartArray as string)
41 | if (data) {
42 | setProducts(data)
43 | }else {
44 | setProducts([])
45 | }
46 | }
47 |
48 |
49 | const getTotalPaymentAndProductCount = useMemo(() => {
50 | let total = 0
51 | let count = 0
52 | products.forEach((product: ProductPreview) => {
53 | total += product.Price
54 | count++
55 | })
56 | return { total, count }
57 | }, [products])
58 |
59 | const placeOrder = async () => {
60 | if (userState) {
61 | await removeAllFromCart()
62 | setProducts([])
63 | setModalIsOpen(true)
64 | } else {
65 | router.push('/auth')
66 | }
67 | }
68 |
69 | return (
70 |
71 |
72 |
Order Summary
73 |
{getTotalPaymentAndProductCount.count} Items
74 |
Total Payment
75 |
{getTotalPaymentAndProductCount.total.toLocaleString()} $
76 |
79 |
80 |
102 |
103 |
router.push('/')}>
104 |
105 |
106 |
107 |
108 |
109 |
Order is successful
110 |
Thank you for choosing us
111 |
112 |
113 |
114 |
115 | )
116 | }
117 |
118 | export default index
119 |
--------------------------------------------------------------------------------
/frontend/src/context/UserContext/UserContext.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useReducer, useState, useEffect } from 'react'
2 | import { API_AddOneToCart, API_RemoveAllFromCart, API_RemoveOneFromCart } from 'src/utils/api'
3 | import { IUserContext, Action, User, ActionCart } from './interfaces'
4 |
5 | export const UserContext = createContext(undefined)
6 |
7 | const userReducer = (state: User | null, action: Action): User | null => {
8 | let oldState = null
9 | switch (action.type) {
10 | case 'save':
11 | const newState = { ...(action.payload as User) }
12 | newState.shoppingCart = Array.from(new Set(newState.shoppingCart))
13 | return newState
14 |
15 | case 'delete':
16 | return null
17 |
18 | case 'update-cart':
19 | return { ...(state as User), shoppingCart: action.payload as string[] }
20 |
21 | case 'delete-from-cart-one':
22 | oldState = { ...state } as User
23 | oldState.shoppingCart.filter(e => e !== (action.payload as string))
24 | return oldState
25 |
26 | case 'delete-from-cart-all':
27 | oldState = { ...state } as User
28 | oldState.shoppingCart = []
29 | return oldState
30 |
31 | case 'save-cart':
32 | oldState = { ...state } as User
33 | oldState.shoppingCart = action.payload as string[]
34 | return state
35 |
36 | default:
37 | return state
38 | }
39 | }
40 | const cartInLocalStorageReducer = (state: string, action: ActionCart): string => {
41 | let oldState = state
42 |
43 | switch (action.type) {
44 | case 'add-to-cart-one':
45 | oldState += action.payload + ','
46 | window.localStorage.setItem('cart', oldState)
47 | return oldState
48 |
49 | case 'delete-from-cart-one':
50 | const deleted = oldState.replace(action.payload + ',', '')
51 | window.localStorage.setItem('cart', deleted)
52 | return deleted
53 |
54 | case 'delete-from-cart-all':
55 | window.localStorage.setItem('cart', '')
56 | return ''
57 |
58 | default:
59 | return state
60 | }
61 | }
62 |
63 | export const UserContextProvider: React.FC = ({ children }) => {
64 | const [userState, dispatchUserState] = useReducer(userReducer, null, () => null)
65 | const [accessToken, setAccessToken] = useState(null)
66 | const [cartInLocalStorage, dispatchCartInLocalStorage] = useReducer(cartInLocalStorageReducer, [], () => {
67 | try {
68 | const item = window.localStorage.getItem('cart')
69 | if (item) {
70 | return item
71 | } else {
72 | return ''
73 | }
74 | } catch (error) {
75 | return ''
76 | }
77 | })
78 |
79 | const addOneToCart = async (id: string) => {
80 | const type = 'add-to-cart-one'
81 | if (userState && accessToken) {
82 | const newCart = await API_AddOneToCart(id, accessToken)
83 | if (newCart) {
84 | dispatchUserState({
85 | type: 'update-cart',
86 | payload: newCart,
87 | })
88 | }
89 | } else {
90 | dispatchCartInLocalStorage({ type: type, payload: id })
91 | }
92 | }
93 | const removeOneFromCart = async (id: string) => {
94 | const type = 'delete-from-cart-one'
95 | if (userState && accessToken) {
96 | const newCart = await API_RemoveOneFromCart(id, accessToken)
97 | if (!newCart) return
98 |
99 | dispatchUserState({
100 | type: 'update-cart',
101 | payload: newCart,
102 | })
103 | } else {
104 | dispatchCartInLocalStorage({ type: type, payload: id })
105 | }
106 | }
107 | const removeAllFromCart = async () => {
108 | const type = 'delete-from-cart-all'
109 | if (userState && accessToken) {
110 | const result = await API_RemoveAllFromCart(accessToken)
111 | dispatchUserState({
112 | type: 'delete-from-cart-all',
113 | payload: result,
114 | })
115 | } else {
116 | dispatchCartInLocalStorage({ type: type, payload: '' })
117 | }
118 | }
119 |
120 | return (
121 |
134 | {children}
135 |
136 | )
137 | }
138 |
139 | export const useUserContext = () => {
140 | const context = useContext(UserContext)
141 | if (context === undefined) {
142 | throw new Error('UserContext must be used within a UserProvider')
143 | }
144 | return context
145 | }
146 |
--------------------------------------------------------------------------------
/frontend/src/utils/api.ts:
--------------------------------------------------------------------------------
1 | export const BASE_URL = 'https://computer-store-demo.herokuapp.com'
2 |
3 | export const API_Login = async (email: string, password: string) => {
4 | try {
5 | const response = await fetch(`${BASE_URL}/auth/login`, {
6 | method: 'POST',
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | },
10 | body: JSON.stringify({
11 | email: email,
12 | password: password,
13 | }),
14 | })
15 |
16 | if (response.status === 401) {
17 | return { error: 'Email or Password is invalid. Please try again later.' }
18 | }
19 | if (response.status !== 201) {
20 | return { error: 'Something went wrong. Please try again later' }
21 | }
22 |
23 | const data = await response.json()
24 | return data
25 | } catch (error) {
26 | return { error: 'Something went wrong. Please try again later' }
27 | }
28 | }
29 | export const API_Signup = async (name: string, email: string, password: string) => {
30 | try {
31 | const response = await fetch(`${BASE_URL}/auth/register`, {
32 | method: 'POST',
33 | headers: {
34 | 'Content-Type': 'application/json',
35 | },
36 | body: JSON.stringify({
37 | name: name,
38 | email: email,
39 | password: password,
40 | }),
41 | })
42 |
43 | if (response.status === 401) {
44 | return { error: 'Email or Password is invalid. Please try again later' }
45 | }
46 |
47 | if (response.status === 409) {
48 | return { error: 'Email already exist' }
49 | }
50 |
51 | if (response.status !== 201) {
52 | return { error: 'Something went wrong. Please try again later' }
53 | }
54 |
55 | const data = await response.json()
56 | return data
57 | } catch (error) {
58 | return { error: 'Something went wrong. Please try again later' }
59 | }
60 | }
61 |
62 | export const API_GetUser = async (accessToken: string) => {
63 | try {
64 | const response = await fetch(`${BASE_URL}/user`, {
65 | method: 'GET',
66 | headers: {
67 | 'content-type': 'application/json',
68 | authorization: 'Bearer ' + accessToken,
69 | },
70 | })
71 |
72 | if (response.status === 401) {
73 | return null
74 | }
75 | const data = await response.json()
76 |
77 | return data
78 | } catch (error) {
79 | return null
80 | }
81 | }
82 |
83 | export const API_GetProducts = async (cartArray: string) => {
84 | try {
85 | const response = await fetch(`${BASE_URL}/product/find-many?idArray=${cartArray}`)
86 | const data = await response.json()
87 |
88 | if (data.error) {
89 | return []
90 | }
91 |
92 | return data
93 | } catch (error) {
94 | return []
95 | }
96 | }
97 |
98 | export const API_MergeLocalStorageCartWithDatabase = async (accessToken: string) => {
99 | try {
100 | const cart = window.localStorage.getItem('cart')
101 | if (!cart) return
102 |
103 | await fetch(`${BASE_URL}/user/cart/add?productId=${cart.slice(0, -1)}`, {
104 | method: 'POST',
105 | headers: {
106 | 'Content-Type': 'application/json',
107 | authorization: 'Bearer ' + accessToken,
108 | },
109 | })
110 | window.localStorage.removeItem('cart')
111 | } catch (error) {}
112 | }
113 |
114 | export const API_AddOneToCart = async (id: string, accessToken: string) => {
115 | try {
116 | const response = await fetch(`${BASE_URL}/user/cart/add?productId=${id}`, {
117 | method: 'POST',
118 | headers: {
119 | 'Content-Type': 'application/json',
120 | authorization: 'Bearer ' + accessToken,
121 | },
122 | })
123 | const data = await response.json()
124 |
125 | if (data.error) {
126 | return null
127 | } else {
128 | return data
129 | }
130 | } catch (error) {}
131 | }
132 |
133 | export const API_RemoveOneFromCart = async (id: string, accessToken: string) => {
134 | try {
135 | const result = await fetch(`${BASE_URL}/user/cart/remove?productId=${id}`, {
136 | method: 'POST',
137 | headers: {
138 | 'Content-Type': 'application/json',
139 | authorization: 'Bearer ' + accessToken,
140 | },
141 | })
142 | const data = await result.json()
143 | return data
144 | } catch (error) {
145 | return null
146 | }
147 | }
148 |
149 | export const API_RemoveAllFromCart = async (accessToken: string) => {
150 | try {
151 | const result = await fetch(`${BASE_URL}/user/cart/remove-all`, {
152 | method: 'POST',
153 | headers: {
154 | 'Content-Type': 'application/json',
155 | authorization: 'Bearer ' + accessToken,
156 | },
157 | })
158 | const data = await result.json()
159 | return data
160 | } catch (error) {
161 | return null
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/frontend/pages/product/[id].tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { GetStaticPaths, GetStaticProps } from 'next'
3 | import { useRouter } from 'next/router'
4 | import styles from './product-page.module.css'
5 | import Link from 'next/link'
6 | import ErrorPage from 'next/error'
7 | import { motion } from 'framer-motion'
8 | import { stagger, fadeInUp } from 'src/components/Animations/Animations'
9 | import { BASE_URL } from 'src/utils/api'
10 | import { useUserContext } from 'src/context/UserContext/UserContext'
11 | import cx from 'classnames'
12 | interface Props {
13 | product: any
14 | }
15 |
16 | const specFilter = ['Part', 'Type', 'Images', 'SellerName', 'Seller', 'Name', '_id']
17 |
18 | const Product = ({ product }: Props) => {
19 | const { addOneToCart,userState ,removeOneFromCart} = useUserContext()
20 | const { isFallback } = useRouter()
21 |
22 | if (!isFallback && !product?._id) {
23 | return
24 | }
25 |
26 | const checkIsInCart = () => {
27 | if(typeof window === "undefined") return false
28 |
29 | if (userState) {
30 | if (userState.shoppingCart.includes(product._id)) return true
31 | } else {
32 | if (window.localStorage.getItem('cart')?.includes(product._id+ ',')) return true
33 | }
34 |
35 | return false
36 | }
37 |
38 | return (
39 |
40 | {isFallback ? (
41 |
Loading...
42 | ) : (
43 | <>
44 |
45 |
51 |
62 |
63 |
64 |
65 |
66 | {product.Manufacturer}
67 |
68 |
69 |
70 | {product.Name}
71 |
72 |
73 | Seller:
74 |
75 | {product.SellerName}
76 |
77 |
78 |
79 | {product.Price}$
80 |
81 | {checkIsInCart() ? (
82 |
85 | ) : (
86 |
96 | )}
97 |
98 |
99 |
100 |
101 |
102 |
103 | {Object.keys(product).map((e: string) => {
104 | if (specFilter.includes(e)) return null
105 | else {
106 | return (
107 |
108 | | {e} |
109 | {product[e]} |
110 |
111 | )
112 | }
113 | })}
114 |
115 |
116 |
117 | >
118 | )}
119 |
120 | )
121 | }
122 |
123 | export const getStaticProps: GetStaticProps = async ({ params }) => {
124 | const res = await fetch(`${BASE_URL}/product/${params?.id}`)
125 | const product = await res.json()
126 |
127 | return { props: { product } }
128 | }
129 |
130 | export const getStaticPaths: GetStaticPaths = async () => {
131 | const res = await fetch(`${BASE_URL}/product/get-all-ids`)
132 | const ids = await res.json()
133 |
134 | const paths = ids.map((id: string) => ({
135 | params: { id: id } || '',
136 | }))
137 |
138 | return { paths, fallback: false }
139 | }
140 |
141 | export default Product
142 |
--------------------------------------------------------------------------------
/frontend/src/components/Icons/NotFoundIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import styles from './icon.module.css'
4 |
5 | interface Props {
6 | text: string
7 | }
8 | const NotFoundIcon = ({ text }: Props) => {
9 | return (
10 |
11 |
{text}
12 |
30 |
31 | )
32 | }
33 |
34 | export default NotFoundIcon
35 |
--------------------------------------------------------------------------------
/server/src/user/user.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | ConflictException,
4 | InternalServerErrorException,
5 | NotFoundException,
6 | } from '@nestjs/common';
7 | import { InjectModel } from '@nestjs/mongoose';
8 | import { Model } from 'mongoose';
9 | import { User } from '../user/interfaces/user.interface';
10 | import { AuthCredentialsDto } from 'src/auth/dto/auth-credentials-dto';
11 | import * as bcrypt from 'bcrypt';
12 | import { UserDto } from './dto/user.dto';
13 | import { Product } from 'src/product/interfaces/product.interface';
14 | import { JwtPayload } from 'src/auth/interfaces/jwt-payload.interface';
15 | import { JwtService } from '@nestjs/jwt';
16 |
17 | @Injectable()
18 | export class UserService {
19 | constructor(
20 | private readonly jwtService: JwtService,
21 | @InjectModel('user') private readonly userModel: Model,
22 | @InjectModel('product') private readonly productModel: Model,
23 | ) {}
24 |
25 | async getUser(userId: string): Promise {
26 | const found = (await this.userModel.findById(userId).lean().exec()) as User;
27 |
28 | if (!found) {
29 | throw new NotFoundException(`Product with ID "${userId}" not found`);
30 | }
31 |
32 | return found;
33 | }
34 | async updateUser(userId: string, userDto: UserDto): Promise {
35 | const updatedUser = await this.userModel.findByIdAndUpdate(
36 | userId,
37 | userDto,
38 | { new: true, select: '-shoppingCart' },
39 | );
40 |
41 | return updatedUser;
42 | }
43 | async getShoppingCart(userId: string): Promise {
44 | const user = await this.userModel
45 | .findOne({
46 | _id: userId,
47 | })
48 | .select('shoppingCart');
49 |
50 | const populateOptions = {
51 | path: 'shoppingCart',
52 | options: { sort: '-Price' },
53 | };
54 | await user.populate(populateOptions).execPopulate();
55 |
56 | return user;
57 | }
58 |
59 | async addOneToShoppingCart(
60 | productId: string,
61 | userId: string,
62 | ): Promise {
63 | const productArray = productId.split(',');
64 | const updatedShoppingCart = await this.userModel.findOneAndUpdate(
65 | {
66 | _id: userId,
67 | },
68 | { $push: { shoppingCart: { $each: productArray } } },
69 | { new: true },
70 | );
71 |
72 | if (!updatedShoppingCart) {
73 | throw new NotFoundException(`Something went wrong when updating cart`);
74 | }
75 |
76 | return updatedShoppingCart.shoppingCart;
77 | }
78 | async removeOneFromShoppingCart(
79 | productId: string,
80 | userId: string,
81 | ): Promise {
82 | const updatedShoppingCart = await this.userModel.findOneAndUpdate(
83 | {
84 | _id: userId,
85 | },
86 | { $pull: { shoppingCart: productId } },
87 | { new: true },
88 | );
89 |
90 | if (!updatedShoppingCart) {
91 | throw new NotFoundException(`Something went wrong when updating cart`);
92 | }
93 |
94 | return updatedShoppingCart.shoppingCart;
95 | }
96 |
97 | async removeAllFromShoppingCart(userId: string): Promise {
98 | const updatedShoppingCart = await this.userModel.findOneAndUpdate(
99 | {
100 | _id: userId,
101 | },
102 | { $set: { shoppingCart: [] } },
103 | { new: true },
104 | );
105 |
106 | if (!updatedShoppingCart) {
107 | throw new NotFoundException(`Something went wrong when updating cart`);
108 | }
109 | return updatedShoppingCart.shoppingCart;
110 | }
111 | async getOrder(userId: string): Promise {
112 | const found = (await this.userModel
113 | .findById(userId)
114 | .select('orders')
115 | .lean()
116 | .exec()) as User;
117 |
118 | if (!found) {
119 | throw new NotFoundException(`Product with ID "${userId}" not found`);
120 | }
121 |
122 | return found;
123 | }
124 | async addOrder(userId: string, productsId: string[]): Promise {
125 | const products = (await this.productModel
126 | .find({
127 | _id: {
128 | $in: productsId,
129 | },
130 | })
131 | .exec()) as Product[];
132 |
133 | await this.userModel.findOneAndUpdate(
134 | {
135 | _id: userId,
136 | },
137 | { $push: { orders: { $each: products } } },
138 | { new: true },
139 | );
140 | return { data: 'Purchased' };
141 | }
142 |
143 | async register(
144 | authCredentialsDto: AuthCredentialsDto,
145 | ): Promise<{ accessToken: string }> {
146 | authCredentialsDto.password = await this.hashPassword(
147 | authCredentialsDto.password,
148 | 10,
149 | );
150 | const newUser = new this.userModel(authCredentialsDto);
151 |
152 | try {
153 | await newUser.save();
154 |
155 | const payload: JwtPayload = {
156 | email: authCredentialsDto.email,
157 | id: newUser.id,
158 | };
159 |
160 | const accessToken = this.jwtService.sign(payload);
161 |
162 | return { accessToken: accessToken };
163 | } catch (error) {
164 | // duplicate
165 | if (error.code === 11000) {
166 | throw new ConflictException('Email already exist');
167 | } else {
168 | throw new InternalServerErrorException();
169 | }
170 | }
171 | }
172 |
173 | async validateUserPassword(password: string, email: string): Promise {
174 | const user = await this.userModel
175 | .findOne({
176 | email: email,
177 | })
178 | .select('password email')
179 | .exec();
180 |
181 | if (user && (await user.validatePassword(password, user.password))) {
182 | return user;
183 | } else {
184 | return null;
185 | }
186 | }
187 |
188 | private async hashPassword(
189 | password: string,
190 | saltRound: number,
191 | ): Promise {
192 | return bcrypt.hash(password, saltRound);
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/frontend/public/sw.js:
--------------------------------------------------------------------------------
1 | if(!self.define){const e=e=>{"require"!==e&&(e+=".js");let s=Promise.resolve();return n[e]||(s=new Promise((async s=>{if("document"in self){const n=document.createElement("script");n.src=e,document.head.appendChild(n),n.onload=s}else importScripts(e),s()}))),s.then((()=>{if(!n[e])throw new Error(`Module ${e} didn’t register its module`);return n[e]}))},s=(s,n)=>{Promise.all(s.map(e)).then((e=>n(1===e.length?e[0]:e)))},n={require:Promise.resolve(s)};self.define=(s,c,i)=>{n[s]||(n[s]=Promise.resolve().then((()=>{let n={};const r={uri:location.origin+s.slice(1)};return Promise.all(c.map((s=>{switch(s){case"exports":return n;case"module":return r;default:return e(s)}}))).then((e=>{const s=i(...e);return n.default||(n.default=s),n}))})))}}define("./sw.js",["./workbox-030153e1"],(function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/05d954cf.0497b2fa87157cce822d.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/5701f7568478866651b3d46d2a1812a76e3cedeb.d79059b7934b94a4da57.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/8e44ad46093399ebdd1a4aafef844609b57ea965.2c3d6b361dfa9c94c647.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/9f481e23185e9ad21cb805621132b14ec3e79ec6.0ace122984c7304f7afe.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/c81b22600ae917f501bbbd2ab5726c822304f6f5.0bc2484fb825f72a679b.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/commons.8433c7b5d2ef072ed9ab.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/framework.9ec1f7868b3e9d138cdd.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/main-cb9291afcdfc692b5269.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/_app-3b294c9d4ecf336f2292.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/_error-6fccc11d2ad33e2612b2.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/_home/Home-7e80657d219d06939afa.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/auth-2c9630834c9fc61c11c1.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/cart-23b1c63586d81a06f572.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/index-35c12a6c41b4f8043ab9.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/pages/product/%5Bid%5D-94583a0e48ee502d0804.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/polyfills-608493d412e67f440912.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/chunks/webpack-e067438c4cf4ef2ef178.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/css/0d34a269afafb6d10c84.css",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/css/2af099355b042753762e.css",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/css/398a4fc3701d22b04e54.css",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/css/9444512b2446b20bbe40.css",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/css/9792985483d36efffb25.css",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/wsjDZTKEx-TU3h57YpMVy/_buildManifest.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/_next/static/wsjDZTKEx-TU3h57YpMVy/_ssgManifest.js",revision:"wsjDZTKEx-TU3h57YpMVy"},{url:"/favicon.ico",revision:"8d24ec6a25d48532fb33e86a4bc4d618"},{url:"/icons/icon-114x114.png",revision:"1e1cd4ef5fca146fd7806d0ef8590e71"},{url:"/icons/icon-120x120.png",revision:"ae50fc4a4788eaa5a2c04d1e66fec048"},{url:"/icons/icon-144x144.png",revision:"eb7fa8b91f1077430b508bd700e91e3e"},{url:"/icons/icon-152x152.png",revision:"83bf295ba1584346c07626815ab28f4d"},{url:"/icons/icon-16x16.png",revision:"21a73c00ed048b894b8536a48af5e35b"},{url:"/icons/icon-180x180.png",revision:"b246036ecd56b07d8e3e637ea73c2856"},{url:"/icons/icon-192x192.png",revision:"b09cc11646e2cd81a97d6ed4618b2cba"},{url:"/icons/icon-32x32.png",revision:"2b775c009f9e73f495248069eb8df292"},{url:"/icons/icon-512x512.png",revision:"ce02771a89307dc3e66c07dd15728cdd"},{url:"/icons/icon-57x57.png",revision:"ff0878f569a28134e9e91809689b4b79"},{url:"/icons/icon-60x60.png",revision:"278924f1c31a94765984eb2dfc1e9c74"},{url:"/icons/icon-72x72.png",revision:"d07810dd35125c499b8f42eb637a4a91"},{url:"/icons/icon-76x76.png",revision:"1dda16f089e7d8f45c9f480c86987e94"},{url:"/icons/icon-96x96.png",revision:"951a9d7868ac174b46492bcca38657f3"},{url:"/manifest.json",revision:"d6fab0befc7ad658f6eaa40d748df655"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[new e.ExpirationPlugin({maxEntries:1,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/\/api\/.*$/i,new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET"),e.registerRoute(/.*/i,new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400,purgeOnQuotaError:!0})]}),"GET")}));
2 |
--------------------------------------------------------------------------------
/frontend/src/components/Slider/Slider.tsx:
--------------------------------------------------------------------------------
1 | import React, { useReducer, useRef, useEffect } from 'react'
2 |
3 | import cx from 'classnames'
4 | import styles from './Slider.module.css'
5 | import { useDebounce } from '../../hooks/useDebounce'
6 | import { useFilterContext } from 'src/context/FilterContext/FilterContext'
7 | import { SliderLabelSymbols } from 'src/utils/constants'
8 | interface Props {
9 | title: string
10 | minRange: number
11 | maxRange: number
12 | }
13 | interface SliderState {
14 | firstSliderValue: number
15 | secondSliderValue: number
16 | minValue: number
17 | maxValue: number
18 | maxRange: number
19 | minRange: number
20 | sliderColor: string
21 | }
22 | type Action = {
23 | type: 'input-range-1' | 'input-range-2' | 'changed'
24 | payload: {
25 | firstSliderValue: number
26 | secondSliderValue: number
27 | }
28 | }
29 |
30 | const sliderReducer = (state: SliderState, action: Action): SliderState => {
31 | switch (action.type) {
32 | case 'changed':
33 | const { firstSliderValue, secondSliderValue } = action.payload
34 |
35 | let minValue = firstSliderValue
36 | let maxValue = secondSliderValue
37 |
38 | if (firstSliderValue > secondSliderValue) {
39 | minValue = secondSliderValue
40 | maxValue = firstSliderValue
41 | }
42 |
43 | const sliderColor = calculateSliderColor(minValue, maxValue, state.minRange, state.maxRange)
44 |
45 | const newState = {
46 | firstSliderValue,
47 | secondSliderValue,
48 | minValue,
49 | maxValue,
50 | sliderColor,
51 | }
52 |
53 | return { ...state, ...newState }
54 | default:
55 | return { ...state }
56 | }
57 | }
58 |
59 | function calculateSliderColor(minValue: number, maxValue: number, minRange: number, maxRange: number) {
60 | let ratioInputMax: number = (100 * (maxValue - minRange)) / (maxRange - minRange)
61 | let ratioInputMin: number = Math.abs(100 - (100 * (minValue - maxRange)) / (minRange - maxRange))
62 |
63 | const bgColor = '#f3f3f4'
64 | const rangeColor = '#1EA7FD'
65 |
66 | return `linear-gradient(to right, ${bgColor} 0%, ${bgColor} ${ratioInputMin}%, ${rangeColor} ${ratioInputMin}%, ${rangeColor} ${ratioInputMax}%, ${bgColor} ${ratioInputMax}%, ${bgColor} 100%)`
67 | }
68 |
69 | const init = (value: string, minRange: number, maxRange: number) => {
70 | const firstValue = parseFloat(value?.split(',')[0]) || minRange
71 | const secondValue = parseFloat(value?.split(',')[1]) || maxRange
72 |
73 | return {
74 | firstSliderValue: firstValue,
75 | secondSliderValue: secondValue,
76 | minValue: firstValue,
77 | maxValue: secondValue,
78 | minRange: minRange,
79 | maxRange: maxRange,
80 | sliderColor: calculateSliderColor(firstValue, secondValue, minRange, maxRange),
81 | }
82 | }
83 | const Slider = ({ title, minRange, maxRange }: Props) => {
84 | const { filterDispatch } = useFilterContext()
85 |
86 | const [sliderState, dispatchSlider] = useReducer(sliderReducer, {}, () => init(title, minRange, maxRange))
87 |
88 | const firstSlider = useRef(null)
89 | const secondSlider = useRef(null)
90 |
91 | const { firstSliderValue, secondSliderValue, minValue, maxValue, sliderColor } = sliderState
92 |
93 | const debouncedValue = useDebounce(`${sliderState.minValue},${sliderState.maxValue}`, 100)
94 |
95 | const dispatchChanged = () => {
96 | if (firstSlider.current === null || secondSlider.current === null) {
97 | return
98 | }
99 |
100 | dispatchSlider({
101 | type: 'changed',
102 | payload: {
103 | firstSliderValue: parseFloat(firstSlider.current.value),
104 | secondSliderValue: parseFloat(secondSlider.current.value),
105 | },
106 | })
107 | }
108 |
109 | useEffect(() => {
110 | const values = debouncedValue.split(',')
111 |
112 | if (parseFloat(values[0]) === minRange && parseFloat(values[1]) === maxRange) {
113 | filterDispatch({ type: 'delete-string', payload: { category: title, value: debouncedValue } })
114 | } else {
115 | filterDispatch({ type: 'add-string', payload: { category: title, value: debouncedValue } })
116 | }
117 | }, [debouncedValue])
118 |
119 | return (
120 |
121 |
{title}
122 |
123 |
124 | {`${minValue}${SliderLabelSymbols[title]}`}
125 |
126 |
127 | {`${maxValue}${SliderLabelSymbols[title]}`}
128 |
129 |
130 |
131 |
132 |
148 |
163 |
164 |
165 | )
166 | }
167 |
168 | export default Slider
169 |
--------------------------------------------------------------------------------
/frontend/src/components/Sidebar/Sidebar.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Sidebar from './Sidebar'
4 |
5 | export default {
6 | component: Sidebar,
7 | title: 'Sidebar',
8 | }
9 |
10 | export const Default = () => {
11 | return
12 | }
13 |
14 | function getData() {
15 | return {
16 | Price: [199, 6499],
17 | Weight: [770, 4898.792],
18 | Manufacturer: {
19 | Asus: 39,
20 | Acer: 39,
21 | Razer: 14,
22 | MSI: 41,
23 | HP: 18,
24 | Apple: 10,
25 | Gigabyte: 5,
26 | Lenovo: 30,
27 | Microsoft: 24,
28 | Dell: 9,
29 | Aorus: 3,
30 | Samsung: 6,
31 | },
32 | 'Screen Size': {
33 | '12': 2,
34 | '13': 4,
35 | '14': 40,
36 | '15': 3,
37 | '16': 2,
38 | '17': 3,
39 | '17.3': 29,
40 | '15.6': 106,
41 | '15.4': 2,
42 | '13.3': 13,
43 | '13.5': 7,
44 | '12.3': 10,
45 | '13.9': 2,
46 | '11.6': 15,
47 | },
48 | 'Screen Panel Type': {
49 | IPS: 138,
50 | OLED: 4,
51 | 'IPS ': 2,
52 | TN: 8,
53 | AHVA: 1,
54 | },
55 | Resolution: {
56 | '1920 x 1080': 148,
57 | '3840 x 2160': 19,
58 | '1920 x 1080 ': 3,
59 | '2880 x 1800': 2,
60 | '3072 x 1920': 2,
61 | '2560 x 1440': 5,
62 | '2496 x 1664': 3,
63 | '2256 x 1504': 7,
64 | '2736 x 1824': 10,
65 | '2880 x 1920': 4,
66 | '2560 x 1600': 6,
67 | '1366 x 768': 25,
68 | '1366 x 912': 2,
69 | '1400 x 900': 1,
70 | '1366 x 768 ': 1,
71 | },
72 | 'Refresh Rate': {
73 | '60': 36,
74 | '120': 14,
75 | '144': 48,
76 | '240': 10,
77 | 'Not Specified': 130,
78 | },
79 | 'CPU Core Count': {
80 | '2': 41,
81 | '4': 83,
82 | '6': 103,
83 | '8': 11,
84 | },
85 | Memory: {
86 | '4': 31,
87 | '6': 2,
88 | '8': 72,
89 | '12': 1,
90 | '16': 115,
91 | '32': 16,
92 | '64': 1,
93 | },
94 | CPU: {
95 | 'Intel Core i9-9980HK': 1,
96 | 'Intel Core i7-8750H': 22,
97 | 'Intel Core i7-9750H': 78,
98 | 'AMD Ryzen 7 2700': 1,
99 | 'Intel Core i9-9980H': 2,
100 | 'Intel Core i7-8650U': 2,
101 | 'AMD Ryzen 7 3780U': 1,
102 | 'Intel Core i7-1065G7': 9,
103 | 'Intel Core i5-9300H': 6,
104 | 'Intel Core i7-8565U': 12,
105 | 'Intel Core i5-8350U': 2,
106 | 'Intel Core i7-10710U': 3,
107 | 'Intel Core i5-8265U': 6,
108 | 'Microsoft SQ1': 4,
109 | 'AMD Ryzen 5 3580U': 3,
110 | 'Intel Core i7-8665U': 3,
111 | 'Intel Core i7-10510U': 2,
112 | 'Intel Core i5-8279U': 2,
113 | 'AMD Ryzen 7 3700U': 1,
114 | 'Intel Core i5-10210U': 3,
115 | 'Intel Core i7-8550U': 1,
116 | 'AMD Ryzen 5 3500U': 14,
117 | 'AMD Ryzen 7 4800HS': 1,
118 | 'Intel Core i5-1035G7': 3,
119 | 'Intel Core i5-1035G4': 5,
120 | 'Intel Core i5-8257U': 2,
121 | 'Intel Core i3-1005G1': 2,
122 | 'Intel Core i5-8250U': 1,
123 | 'Intel Core i7-8565U ': 2,
124 | 'Intel Core i5-8210Y': 2,
125 | 'Intel Core i5-1035G1': 1,
126 | 'AMD Ryzen 5 2500U': 1,
127 | 'AMD Ryzen 5 3350H': 1,
128 | 'Intel Core i3-8145U': 2,
129 | 'AMD Ryzen 3 3200U': 7,
130 | 'Intel Core i3-10110U': 1,
131 | 'Intel Celeron N4000': 25,
132 | 'Intel Celeron N3060': 2,
133 | 'Intel Core i9-9880H': 2,
134 | },
135 | GPU: {
136 | 'NVIDIA GeForce RTX 2080': 10,
137 | 'NVIDIA Quadro RTX 5000': 1,
138 | 'NVIDIA GeForce RTX 2080 Max-Q': 9,
139 | 'NVIDIA GeForce RTX 2070': 11,
140 | 'NVIDIA GeForce GTX 1060 6GB': 4,
141 | 'NVIDIA GeForce RTX 2060': 17,
142 | 'AMD Radeon RX VEGA 56': 1,
143 | 'NVIDIA GeForce RTX 2070 Max-Q': 7,
144 | 'NVIDIA GeForce GTX 1660 Ti': 20,
145 | 'AMD Radeon Pro 560 X': 1,
146 | 'NVIDIA Quadro RTX 3000': 1,
147 | 'AMD Radeon Pro 5500M': 1,
148 | 'AMD Radeon Pro 555 X': 1,
149 | 'Intel UHD Graphics 620': 24,
150 | 'AMD Radeon Pro 5300M': 1,
151 | 'AMD Radeon Vega 11': 1,
152 | 'NVIDIA GeForce GTX 1650': 13,
153 | 'NVIDIA Quadro T2000': 2,
154 | 'NVIDIA GeForce GTX 1650 Max-Q': 7,
155 | 'NVIDIA GeForce GTX 1050 Ti': 3,
156 | 'NVIDIA GeForce GTX 1070 ': 1,
157 | 'Intel Iris Plus Graphics': 15,
158 | 'NVIDIA GeForce GTX 1060 Max-Q': 1,
159 | 'Qualcomm Adreno 685': 4,
160 | 'AMD Radeon Vega 9': 3,
161 | 'NVIDIA GeForce MX150': 3,
162 | 'NVIDIA Quadro T1000': 1,
163 | 'NVIDIA GeForce MX250': 3,
164 | 'Intel Iris Plus 655': 2,
165 | 'AMD Radeon Vega 10': 1,
166 | 'Intel UHD Graphics': 6,
167 | 'NVIDIA Quadro P620': 1,
168 | 'AMD Radeon Vega 8': 14,
169 | 'NVIDIA GeForce GTX 1660 Ti Max-Q': 1,
170 | 'NVIDIA GeForce GTX 1060 3GB': 1,
171 | 'Intel Iris Plus 645': 2,
172 | 'NVIDIA GeForce GTX 1050': 2,
173 | 'Intel UHD Graphics 617': 2,
174 | 'NVIDIA GeForce GTX 1050 Ti ': 1,
175 | 'AMD Radeon RX 560 - 896': 2,
176 | 'Intel HD Graphics 620': 1,
177 | 'AMD Radeon Vega 3': 7,
178 | 'Intel UHD Graphics 600': 25,
179 | 'Intel HD Graphics 400': 2,
180 | 'NVIDIA GeForce GTX 1050 Ti Max-Q': 2,
181 | },
182 | 'Operating System': {
183 | 'Windows 10 Pro': 55,
184 | 'Windows 10 Home': 145,
185 | 'Windows 10 Home ': 3,
186 | 'macOS 10.15 Catalina': 10,
187 | 'Windows 10 Pro ': 1,
188 | 'Google Chrome OS (64-bit)': 17,
189 | 'Windows 10 Pro Education': 2,
190 | 'Windows 10 S': 5,
191 | },
192 | 'SD Card Reader': {
193 | Yes: 72,
194 | No: 166,
195 | },
196 | filterOrder: [
197 | 'Price',
198 | 'Manufacturer',
199 | 'CPU',
200 | 'GPU',
201 | 'CPU Core Count',
202 | 'Memory',
203 | 'Resolution',
204 | 'Refresh Rate',
205 | 'Screen Size',
206 | 'Screen Panel Type',
207 | 'Operating System',
208 | 'SD Card Reader',
209 | 'Weight',
210 | ],
211 | sliders: ['Price', 'Weight'],
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/server/src/product/product.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NotFoundException } from '@nestjs/common';
2 | import { InjectModel } from '@nestjs/mongoose';
3 | import { Model } from 'mongoose';
4 | import { Product } from './interfaces/product.interface';
5 | import { CreateProductDto } from './dto/create-product.dto';
6 | import { User } from 'src/user/interfaces/user.interface';
7 | import { listOfNotCalculate } from 'src/utils/filters';
8 |
9 | @Injectable()
10 | export class ProductService {
11 | private filters;
12 | constructor(
13 | @InjectModel('product') private readonly productModel: Model,
14 | ) {
15 | this.createFilters();
16 | }
17 |
18 | async getProducts(
19 | filter: Record,
20 | ): Promise<{ products: Product[]; numberOfPages: number }> {
21 | const pageSize = 20;
22 |
23 | if (!filter.search) {
24 | const result = (await this.productModel
25 | .find(filter.find)
26 | .sort(filter.sort)
27 | .skip(pageSize * (filter.page - 1))
28 | .limit(pageSize)
29 | .lean()
30 | .exec()) as Product[];
31 |
32 | const documentCount = ((await this.productModel
33 | .find(filter.find)
34 | .countDocuments()
35 | .lean()
36 | .exec()) as unknown) as number;
37 |
38 | const numberOfPages = Math.ceil(documentCount / pageSize);
39 | return { products: result, numberOfPages };
40 | }
41 |
42 | const result = (await this.productModel
43 | .find(
44 | {
45 | $and: [{ $text: { $search: filter.search } }, filter.find],
46 | },
47 | { score: { $meta: 'textScore' } },
48 | )
49 | .sort(filter.sort)
50 | .skip(pageSize * (filter.page - 1))
51 | .limit(pageSize)
52 | .lean()
53 | .exec()) as Product[];
54 |
55 | return { products: result, numberOfPages: null };
56 | }
57 |
58 | async searchProducts(search: string): Promise {
59 | const result = (await this.productModel
60 | .find({ $text: { $search: search } }, { score: { $meta: 'textScore' } })
61 | .sort({ score: { $meta: 'textScore' } })
62 | .lean()
63 | .exec()) as Product[];
64 |
65 | return result;
66 | }
67 |
68 | async getUserProducts(userId: string): Promise {
69 | const result = (await this.productModel
70 | .find({ Seller: userId })
71 | .lean()
72 | .exec()) as Product[];
73 |
74 | return result;
75 | }
76 |
77 | async getProductById(id: string): Promise {
78 | const found = (await this.productModel
79 | .findOne({ _id: id })
80 | .lean()
81 | .exec()) as Product;
82 |
83 | if (!found) {
84 | throw new NotFoundException(`Product with ID "${id}" not found`);
85 | }
86 |
87 | return found;
88 | }
89 |
90 | async getManyProduct(idArray: string): Promise {
91 | try {
92 | const data = (await this.productModel
93 | .find({
94 | _id: { $in: idArray.split(',') },
95 | })
96 | .select('Name Price Images _id')
97 | .lean()
98 | .exec()) as Product[];
99 | return data;
100 | } catch (error) {
101 | throw new NotFoundException(`Product with not found`);
102 | }
103 | }
104 |
105 | async createProduct(createProductDto: CreateProductDto): Promise {
106 | const createdProduct = new this.productModel(createProductDto);
107 | this.createFilters();
108 | return await createdProduct.save();
109 | }
110 |
111 | async updateProduct(
112 | createProductDto: CreateProductDto,
113 | id: string,
114 | user: User,
115 | ): Promise {
116 | const updatedProduct = (await this.productModel
117 | .findOneAndUpdate(
118 | {
119 | _id: id,
120 | Seller: user.id,
121 | },
122 | createProductDto,
123 | { new: true },
124 | )
125 | .lean()
126 | .exec()) as Product;
127 |
128 | if (!updatedProduct) {
129 | throw new NotFoundException(`Product with ID "${id}" not found`);
130 | }
131 | this.createFilters();
132 | return updatedProduct;
133 | }
134 |
135 | async deleteProduct(id: string, user: User): Promise<{ id: string }> {
136 | const found = await this.productModel.findOneAndRemove({
137 | _id: id,
138 | Seller: user.id,
139 | });
140 |
141 | if (!found) {
142 | throw new NotFoundException(`Product with ID "${id}" not found`);
143 | }
144 |
145 | this.createFilters();
146 | return { id };
147 | }
148 |
149 | async getAllIds(): Promise {
150 | const list = await this.productModel.distinct('_id');
151 | return list;
152 | }
153 |
154 | getFilters(): any {
155 | return this.filters;
156 | }
157 |
158 | async createFilters(): Promise {
159 | const filter: any = {};
160 |
161 | const result = (await this.productModel.find().lean().exec()) as Product[];
162 |
163 | const sliders = ['Price', 'Weight'];
164 | sliders.forEach((e) => {
165 | filter[e] = [result[0][e], result[1][e]];
166 | });
167 |
168 | if (!result.length) return;
169 |
170 | result.forEach((e) => {
171 | for (const [key, value] of Object.entries(e)) {
172 | if (listOfNotCalculate.includes(key)) {
173 | continue;
174 | }
175 |
176 | if (sliders.includes(key)) {
177 | if (filter[key][0] > value) {
178 | filter[key][0] = Math.ceil(value);
179 | }
180 | if (filter[key][1] < value) {
181 | filter[key][1] = Math.ceil(value);
182 | }
183 | continue;
184 | }
185 |
186 | if (filter.hasOwnProperty(key)) {
187 | if (filter[key].hasOwnProperty(value)) {
188 | filter[key][value] += 1;
189 | } else {
190 | filter[key][value] = 1;
191 | }
192 | } else {
193 | filter[key] = {};
194 | filter[key][value] = 1;
195 | }
196 | }
197 | });
198 |
199 | filter['filterOrder'] = [
200 | 'Price',
201 | 'Manufacturer',
202 | 'CPU',
203 | 'GPU',
204 | 'CPU Core Count',
205 | 'Memory',
206 | 'Resolution',
207 | 'Refresh Rate',
208 | 'Screen Size',
209 | 'Screen Panel Type',
210 | 'Operating System',
211 | 'SD Card Reader',
212 | 'Weight',
213 | ];
214 | filter['sliders'] = sliders;
215 | this.filters = filter;
216 | }
217 | }
218 |
--------------------------------------------------------------------------------