├── frontend ├── cypress │ ├── support │ │ ├── commands.ts │ │ └── index.ts │ ├── .eslintrc │ ├── tsconfig.json │ └── plugins │ │ └── index.ts ├── .babelrc ├── .prettierignore ├── setupTests.js ├── next-env.d.ts ├── src │ ├── components │ │ ├── Image │ │ │ ├── Image.module.css │ │ │ └── Image.tsx │ │ ├── Logo │ │ │ ├── Logo.stories.tsx │ │ │ ├── logo.module.css │ │ │ └── Logo.tsx │ │ ├── Header │ │ │ ├── Header.stories.tsx │ │ │ ├── Header.module.css │ │ │ └── Header.tsx │ │ ├── Dropdown │ │ │ ├── Dropdown.stories.tsx │ │ │ ├── Dropdown.tsx │ │ │ └── Dropdown.module.css │ │ ├── SearchBar │ │ │ ├── SearchBar.stories.tsx │ │ │ ├── SearchBar.module.css │ │ │ └── SearchBar.tsx │ │ ├── Button │ │ │ ├── Button.module.css │ │ │ ├── ButtonBadge.tsx │ │ │ ├── ButtonBadge.module.css │ │ │ ├── Button.stories.tsx │ │ │ ├── IconButton.tsx │ │ │ ├── Button.tsx │ │ │ └── IconButton.module.css │ │ ├── Icons │ │ │ ├── icon.module.css │ │ │ ├── UserIcon.tsx │ │ │ ├── TrashIcon.tsx │ │ │ ├── SearchIcon.tsx │ │ │ ├── CartIcon.tsx │ │ │ ├── TimesIcon.tsx │ │ │ ├── CheckIcon.tsx │ │ │ └── NotFoundIcon.tsx │ │ ├── Layout │ │ │ ├── site-layout.module.css │ │ │ └── SiteLayout.tsx │ │ ├── Slider │ │ │ ├── Slider.stories.tsx │ │ │ ├── Slider.module.css │ │ │ └── Slider.tsx │ │ ├── Sidebar │ │ │ ├── Sidebar.module.css │ │ │ ├── Sidebar.tsx │ │ │ ├── SidebarSkeleton.module.css │ │ │ ├── SidebarSkeleton.tsx │ │ │ └── Sidebar.stories.tsx │ │ ├── Spinner │ │ │ ├── Spinner.tsx │ │ │ └── Spinner.module.css │ │ ├── Chip │ │ │ ├── Chip.stories.tsx │ │ │ ├── Chip.tsx │ │ │ └── Chip.module.css │ │ ├── Animations │ │ │ └── Animations.ts │ │ ├── Card │ │ │ ├── CardSkeleton.tsx │ │ │ ├── Card.stories.tsx │ │ │ ├── CardList.module.css │ │ │ ├── CardSkeleton.module.css │ │ │ ├── Card.module.css │ │ │ ├── Card.tsx │ │ │ └── CardList.tsx │ │ ├── Modal │ │ │ ├── index.tsx │ │ │ └── Modal.module.css │ │ ├── CheckBox │ │ │ ├── Checkbox.stories.tsx │ │ │ ├── Checkbox.test.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── CheckboxList.module.css │ │ │ ├── Checkbox.module.css │ │ │ ├── CheckboxList.tsx │ │ │ └── CheckboxList.test.tsx │ │ ├── ProductListHeader │ │ │ ├── ProductListHeader.module.css │ │ │ └── ProductListHeader.tsx │ │ └── Form │ │ │ ├── AuthForm.module.css │ │ │ └── AuthForm.tsx │ ├── utils │ │ ├── constants.ts │ │ ├── changeQuery.ts │ │ ├── auth-form.dto.ts │ │ └── api.ts │ ├── hooks │ │ └── useDebounce.tsx │ └── context │ │ ├── UserContext │ │ ├── interfaces.ts │ │ └── UserContext.tsx │ │ └── FilterContext │ │ └── FilterContext.tsx ├── cypress.json ├── public │ ├── favicon.ico │ ├── icons │ │ ├── icon-16x16.png │ │ ├── icon-32x32.png │ │ ├── icon-57x57.png │ │ ├── icon-60x60.png │ │ ├── icon-72x72.png │ │ ├── icon-76x76.png │ │ ├── icon-96x96.png │ │ ├── icon-114x114.png │ │ ├── icon-120x120.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-180x180.png │ │ ├── icon-192x192.png │ │ └── icon-512x512.png │ ├── manifest.json │ └── sw.js ├── pages │ ├── index.tsx │ ├── auth │ │ ├── index.tsx │ │ └── index.module.css │ ├── _app.tsx │ ├── _home │ │ ├── Home.tsx │ │ └── index.module.css │ ├── _document.tsx │ ├── product │ │ ├── product-page.module.css │ │ └── [id].tsx │ └── cart │ │ ├── index.module.css │ │ └── index.tsx ├── .storybook │ ├── main.js │ ├── storybook.css │ ├── postcss.config.js │ ├── preview.js │ └── post-css.js ├── next.config.js ├── testServer.js ├── postcss.config.json ├── .prettierrc ├── .gitignore ├── jest.config.js ├── styles │ ├── variables.css │ └── app.css ├── tsconfig.json ├── README.md └── package.json ├── server ├── Procfile ├── .prettierrc ├── nest-cli.json ├── src │ ├── auth │ │ ├── interfaces │ │ │ └── jwt-payload.interface.ts │ │ ├── decorators │ │ │ └── get-user.decorator.ts │ │ ├── dto │ │ │ └── auth-credentials-dto.ts │ │ ├── auth.controller.ts │ │ ├── jwt.strategy.ts │ │ ├── auth.module.ts │ │ └── auth.service.ts │ ├── product │ │ ├── dto │ │ │ ├── product-filter.dto.ts │ │ │ └── create-product.dto.ts │ │ ├── product.module.ts │ │ ├── interfaces │ │ │ └── product.interface.ts │ │ ├── pipes │ │ │ └── product.filters-pipe.ts │ │ ├── product.field-values.ts │ │ ├── product.schema.ts │ │ ├── product.controller.ts │ │ └── product.service.ts │ ├── utils │ │ └── filters.ts │ ├── main.ts │ ├── user │ │ ├── interfaces │ │ │ └── user.interface.ts │ │ ├── dto │ │ │ └── user.dto.ts │ │ ├── user.module.ts │ │ ├── user.schema.ts │ │ ├── user.controller.ts │ │ └── user.service.ts │ └── app.module.ts ├── tsconfig.build.json ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md └── README.md /frontend/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/Procfile: -------------------------------------------------------------------------------- 1 | web: yarn start:prod 2 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .next 6 | -------------------------------------------------------------------------------- /frontend/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | import './testServer' 3 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /frontend/src/components/Image/Image.module.css: -------------------------------------------------------------------------------- 1 | .img { 2 | min-width: 244px; 3 | min-height: 244px; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "integrationFolder": "cypress/e2e" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/cypress/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["cypress"], 3 | "env": { 4 | "cypress/globals": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | import 'cypress' 2 | import '@testing-library/cypress/add-commands' 3 | import './commands' 4 | -------------------------------------------------------------------------------- /server/src/auth/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | email: string; 3 | id: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/icons/icon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-16x16.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-32x32.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-57x57.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-60x60.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-76x76.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-114x114.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-120x120.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-180x180.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /frontend/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alicanerdurmaz/computer-store/HEAD/frontend/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Home from './_home/Home' 4 | 5 | export default function () { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | type sliderLabelSymbols = { 2 | [key: string]: string 3 | } 4 | 5 | export const SliderLabelSymbols: sliderLabelSymbols = { 6 | Price: '$', 7 | Weight: 'g', 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/Logo/Logo.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Logo from './Logo' 4 | 5 | export default { 6 | component: Logo, 7 | title: 'Logo', 8 | } 9 | 10 | export const Default = () => 11 | -------------------------------------------------------------------------------- /frontend/src/components/Header/Header.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Header from './Header' 4 | 5 | export default { 6 | component: Header, 7 | title: 'Header', 8 | } 9 | 10 | export const Default = () =>
11 | -------------------------------------------------------------------------------- /frontend/src/components/Dropdown/Dropdown.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Dropdown from './Dropdown' 4 | 5 | export default { 6 | component: Dropdown, 7 | title: 'Dropdown', 8 | } 9 | 10 | export const Default = () => 11 | -------------------------------------------------------------------------------- /frontend/src/components/SearchBar/SearchBar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import SearchBar from './SearchBar' 4 | 5 | export default { 6 | component: SearchBar, 7 | title: 'SearchBar', 8 | } 9 | 10 | export const Default = () => 11 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts", 8 | "**/*.stories.tsx", 9 | "typings/browser.d.ts", 10 | "typings/browser" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | cursor: pointer; 3 | padding: 1rem 2rem; 4 | border-radius: 8px; 5 | letter-spacing: 1px; 6 | font-weight: 600; 7 | } 8 | 9 | .ghost { 10 | color: var(--c-text); 11 | background: var(--c-bg-primary); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es5", 6 | "lib": ["es5", "dom"], 7 | "types": ["cypress", "@types/testing-library__cypress", "node"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/icon.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | display: inline-block; 3 | fill: var(--c-icon-color); 4 | } 5 | 6 | .NotFoundIcon { 7 | width: 100%; 8 | height: 200px; 9 | margin: 0 auto; 10 | text-align: center; 11 | } 12 | .text { 13 | font-size: 2rem; 14 | margin-bottom: 1rem; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/site-layout.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 1200px; 3 | 4 | margin: 0 auto; 5 | 6 | display: grid; 7 | grid-template-rows: minmax(80px, max-content) max; 8 | grid-template-areas: 'header' 'body'; 9 | 10 | row-gap: 24px; 11 | 12 | margin-top: 24px; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | module.exports = { 3 | presets: [path.resolve(__dirname, './post-css.js')], 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['@storybook/addon-links', '@storybook/addon-knobs', '@storybook/addon-essentials'], 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/Logo/logo.module.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | color: var(--c-primary); 3 | font-size: 2rem; 4 | font-weight: 700; 5 | width: max-content; 6 | height: max-content; 7 | cursor: pointer; 8 | transition: color 90ms ease-in; 9 | &:hover { 10 | color: var(--c-primary-light); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/utils/changeQuery.ts: -------------------------------------------------------------------------------- 1 | export const createQuery = (queryObject: Record) => { 2 | let queryString = '?' 3 | Object.keys(queryObject).map((key: string) => { 4 | queryString += `&${key}=${queryObject[key].toString()}` 5 | }) 6 | 7 | return queryString.length > 1 ? queryString : '/' 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/components/Slider/Slider.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import Slider from './Slider' 4 | 5 | export default { 6 | component: Slider, 7 | title: 'Slider', 8 | } 9 | 10 | export const Default = () => { 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar/Sidebar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: var(--c-bg-secondary); 3 | border-radius: 8px; 4 | border: 2px solid var(--c-border); 5 | box-shadow: 0px 1px 2px var(--shadow); 6 | 7 | display: flex; 8 | flex-direction: column; 9 | 10 | padding: 1rem; 11 | & > * { 12 | margin: 1rem 0 !important; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/auth/decorators/get-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { User } from 'src/user/interfaces/user.interface'; 3 | 4 | export const GetUser = createParamDecorator( 5 | (data, ctx: ExecutionContext): User => { 6 | const req = ctx.switchToHttp().getRequest(); 7 | return req.user; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const withPWA = require('next-pwa') 2 | const runtimeCaching = require('next-pwa/cache') 3 | 4 | module.exports = withPWA({ 5 | webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { 6 | config.plugins.push(new webpack.IgnorePlugin(/cypress/)) 7 | return config 8 | }, 9 | pwa: { 10 | dest: 'public', 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /frontend/src/components/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import cx from 'classnames' 4 | import styles from './Spinner.module.css' 5 | 6 | interface Props { 7 | className?: string 8 | } 9 | 10 | const Spinner = ({ className }: Props) => { 11 | return
12 | } 13 | 14 | export default Spinner 15 | -------------------------------------------------------------------------------- /frontend/.storybook/storybook.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0 !important; 4 | margin: 0 !important; 5 | 6 | background: var(--c-bg-primary); 7 | 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | body { 13 | margin: 0 auto !important; 14 | max-width: 1200px; 15 | } 16 | 17 | #root { 18 | height: 98%; 19 | width: 98%; 20 | margin: 0 auto; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/Button/ButtonBadge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './ButtonBadge.module.css' 4 | 5 | interface Props { 6 | count: number 7 | } 8 | const ButtonBadge = ({ count }: Props) => { 9 | return ( 10 |
11 | {count} 12 |
13 | ) 14 | } 15 | 16 | export default ButtonBadge 17 | -------------------------------------------------------------------------------- /server/src/product/dto/product-filter.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsNotEmpty, IsNumber } from 'class-validator'; 3 | 4 | export class ProductFilterDto { 5 | @ApiProperty() 6 | @IsOptional() 7 | @IsNotEmpty() 8 | Manufacturer: string; 9 | 10 | @ApiProperty() 11 | @IsNumber() 12 | @IsOptional() 13 | @IsNotEmpty() 14 | 'Refresh Rate': number; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './Image.module.css' 4 | interface Props { 5 | url: string 6 | imageIsLazy?: 'eager' | 'lazy' 7 | } 8 | 9 | const Image: React.FC = ({ url, imageIsLazy = 'eager' }: Props) => { 10 | return notebook image 11 | } 12 | 13 | export default Image 14 | -------------------------------------------------------------------------------- /frontend/testServer.js: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { setupServer } from 'msw/node' 3 | 4 | const server = setupServer() 5 | // rest.get('http://localhost:3001/product/filters', (_req, res, ctx) => { 6 | // return res(ctx.status(200), ctx.json(filtersData)) 7 | // }), 8 | 9 | beforeAll(() => server.listen()) 10 | afterAll(() => server.close()) 11 | afterEach(() => server.resetHandlers()) 12 | 13 | export { server, rest } 14 | -------------------------------------------------------------------------------- /frontend/src/components/Chip/Chip.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Chip from './Chip' 3 | 4 | export default { 5 | component: Chip, 6 | title: 'Chip', 7 | } 8 | 9 | export const Default = () => { 10 | return ( 11 |
16 | alert('click')}> 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /frontend/postcss.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "postcss-custom-properties", 4 | "postcss-nested", 5 | "postcss-flexbugs-fixes", 6 | [ 7 | "postcss-preset-env", 8 | { 9 | "autoprefixer": { 10 | "flexbox": "no-2009", 11 | "grid": "autoplace" 12 | }, 13 | "stage": 3, 14 | "features": { 15 | "custom-properties": true 16 | } 17 | } 18 | ] 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /frontend/.storybook/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'postcss-preset-env': { 5 | stage: 3, 6 | autoprefixer: { 7 | flexbox: 'no-2009', 8 | grid: 'autoplace', 9 | features: { 10 | 'custom-properties': true, 11 | }, 12 | }, 13 | }, 14 | 'postcss-nested': {}, 15 | 'postcss-custom-properties': {}, 16 | 'postcss-flexbugs-fixes': {}, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 120, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/components/Button/ButtonBadge.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | right: 0; 4 | } 5 | .counter { 6 | border: 1px solid #fadde1; 7 | position: absolute; 8 | 9 | background-color: var(--c-bg-secondary); 10 | color: #ff5d8f; 11 | 12 | font-weight: 700; 13 | text-align: center; 14 | 15 | top: -29px; 16 | right: -18px; 17 | 18 | font-size: 0.825rem; 19 | width: 1rem; 20 | height: 1rem; 21 | border-radius: 1rem; 22 | 23 | line-height: 18px; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/Animations/Animations.ts: -------------------------------------------------------------------------------- 1 | export const easing = [0.6, -0.05, 0.01, 0.99] 2 | export const stagger = { 3 | animate: { 4 | transition: { 5 | staggerChildren: 0.05, 6 | }, 7 | }, 8 | } 9 | export const fadeInUp = { 10 | initial: { 11 | y: 60, 12 | opacity: 0, 13 | transition: { duration: 0.6, ease: easing }, 14 | }, 15 | animate: { 16 | y: 0, 17 | opacity: 1, 18 | transition: { 19 | duration: 0.6, 20 | ease: easing, 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /server/src/utils/filters.ts: -------------------------------------------------------------------------------- 1 | export const listOfNotCalculate = [ 2 | 'Part', 3 | 'Type', 4 | 'Dimensions', 5 | 'Battery Capacity', 6 | 'Battery Life', 7 | 'CPU Microarchitecture', 8 | 'SSD Storage', 9 | 'SSD Type', 10 | 'Storage', 11 | 'Front Facing Webcam', 12 | 'Images', 13 | 'Name', 14 | 'GPU Memory', 15 | 'Model', 16 | 'Screen Surface Finish', 17 | 'HDD Storage', 18 | 'Rear Facing Webcam', 19 | '_id', 20 | 'SellerName', 21 | 'Seller', 22 | '0', 23 | 'CPU Boost Clock', 24 | 'CPU Core Clock', 25 | ]; 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # compiled output 4 | /dist 5 | /node_modules 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json -------------------------------------------------------------------------------- /frontend/.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | cypress/videos 33 | cypress/screenshots 34 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules"], 18 | "typeRoots": ["node_modules/@types"] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/Layout/SiteLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Header from '../Header/Header' 3 | 4 | import cx from 'classnames' 5 | import styles from './site-layout.module.css' 6 | import { FilterContextProvider } from 'src/context/FilterContext/FilterContext' 7 | 8 | const SiteLayout: React.FC = ({ children }) => { 9 | return ( 10 |
11 | 12 |
13 | {children} 14 | 15 |
16 | ) 17 | } 18 | 19 | export default SiteLayout 20 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts', '!**/node_modules/**'], 3 | setupFilesAfterEnv: ['/setupTests.js'], 4 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 5 | transform: { 6 | '^.+\\.(js|jsx|ts|tsx)$': '/node_modules/babel-jest', 7 | }, 8 | transformIgnorePatterns: ['/node_modules/', '^.+\\.module\\.(css|sass|scss)$'], 9 | moduleNameMapper: { 10 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 11 | }, 12 | moduleDirectories: ['node_modules', __dirname], 13 | } 14 | -------------------------------------------------------------------------------- /server/src/product/product.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductController } from './product.controller'; 3 | import { ProductService } from './product.service'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { productSchema } from './product.schema'; 6 | import { AuthModule } from 'src/auth/auth.module'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([{ name: 'product', schema: productSchema }]), 11 | AuthModule, 12 | ], 13 | controllers: [ProductController], 14 | providers: [ProductService], 15 | }) 16 | export class ProductModule {} 17 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.enableCors(); 8 | 9 | const options = new DocumentBuilder() 10 | .setTitle('Computer Store') 11 | .setDescription('Computer Store API') 12 | .setVersion('1.0') 13 | .build(); 14 | const document = SwaggerModule.createDocument(app, options); 15 | SwaggerModule.setup('api', app, document); 16 | 17 | await app.listen(process.env.PORT || 3001); 18 | } 19 | bootstrap(); 20 | -------------------------------------------------------------------------------- /server/src/user/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Product } from 'src/product/interfaces/product.interface'; 3 | 4 | export interface User extends mongoose.Document { 5 | name: string; 6 | 7 | password: string; 8 | 9 | email: string; 10 | 11 | addresses: [ 12 | { 13 | addressName: string; 14 | city: string; 15 | country: string; 16 | address: string; 17 | }, 18 | ]; 19 | 20 | phone: number; 21 | 22 | shoppingCart: string[]; 23 | 24 | orders: Product[]; 25 | 26 | validatePassword: ( 27 | enteredPassword: string, 28 | hashedPassword: string, 29 | ) => Promise; 30 | } 31 | -------------------------------------------------------------------------------- /frontend/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --c-primary: #1b4965; 3 | --c-primary-light: #62b6cb; 4 | 5 | --c-text-on-p: #ffffff; 6 | --c-text-on-s: #0d0c22; 7 | 8 | --c-text: #454545; 9 | --c-text-dark: #111111; 10 | --c-text-secondary: #6e6d7a; 11 | 12 | --c-icon-color: #6e6d7a; 13 | 14 | --c-bg-primary: #fafafa; 15 | --c-bg-primary-dark: #c7c7c7; 16 | 17 | --c-bg-secondary: #ffffff; 18 | --c-bg-secondary-dark: #7e8a97; 19 | --c-bg-secondary-darker: #919191; 20 | 21 | --c-border: #f3f3f4; 22 | --shadow: rgba(0, 0, 0, 0.2); 23 | 24 | --a-60-cubic: 60ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; 25 | 26 | --green500: #48bb78; 27 | --c-pale-pink: #fdcfdf; 28 | } 29 | -------------------------------------------------------------------------------- /server/src/auth/dto/auth-credentials-dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString, MinLength, MaxLength, Matches } from 'class-validator'; 3 | 4 | export class AuthCredentialsDto { 5 | @ApiProperty() 6 | @IsString() 7 | @MaxLength(30) 8 | name: string; 9 | 10 | @ApiProperty() 11 | @Matches(/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, { 12 | message: 'E-Mail is not valid.', 13 | }) 14 | email: string; 15 | 16 | @ApiProperty() 17 | @IsString() 18 | @MinLength(8) 19 | @MaxLength(20) 20 | @Matches(/(?:(?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, { 21 | message: 'Password Too Weak', 22 | }) 23 | password: string; 24 | } 25 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ProductModule } from './product/product.module'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { UserModule } from './user/user.module'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot({ isGlobal: true }), 12 | MongooseModule.forRoot(process.env.MONGODB_URI, { 13 | useFindAndModify: false, 14 | useNewUrlParser: true, 15 | useCreateIndex: true, 16 | }), 17 | ProductModule, 18 | AuthModule, 19 | UserModule, 20 | ], 21 | }) 22 | export class AppModule {} 23 | -------------------------------------------------------------------------------- /frontend/src/components/Card/CardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './CardSkeleton.module.css' 4 | import cx from 'classnames' 5 | 6 | const CardSkeleton = () => { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ) 18 | } 19 | 20 | export default CardSkeleton 21 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | // parserOptions: { 4 | // project: './tsconfig.json', 5 | // sourceType: 'module', 6 | // }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /server/src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString, MaxLength, Matches } from 'class-validator'; 3 | 4 | export class UserDto { 5 | @ApiProperty() 6 | @IsNotEmpty() 7 | @MaxLength(30) 8 | @IsString() 9 | name: string; 10 | 11 | @ApiProperty() 12 | @Matches(/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/, { 13 | message: 'E-Mail is not valid.', 14 | }) 15 | @IsNotEmpty() 16 | email: string; 17 | 18 | @ApiProperty() 19 | addresses: [ 20 | { 21 | addressName: string; 22 | city: string; 23 | country: string; 24 | address: string; 25 | }, 26 | ]; 27 | 28 | @ApiProperty() 29 | phone: number; 30 | 31 | @ApiProperty() 32 | shoppingCart: string[]; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/Chip/Chip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './Chip.module.css' 4 | 5 | interface Props { 6 | category: string 7 | value: string 8 | onClick: (event: React.MouseEvent) => void 9 | leftIcon?: any 10 | } 11 | const Chip = ({ category, value, onClick, leftIcon }: Props) => { 12 | return ( 13 |
14 | {leftIcon && ( 15 |
16 | {leftIcon} 17 |
18 | )} 19 | 20 |
21 |
{category}
22 |
{value}
23 |
24 |
25 | ) 26 | } 27 | 28 | export default Chip 29 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import IconButton from './IconButton' 4 | 5 | import UserIcon from '../Icons/UserIcon' 6 | import CartIcon from '../Icons/CartIcon' 7 | import ButtonBadge from './ButtonBadge' 8 | import Button from './Button' 9 | 10 | export default { 11 | component: Button, 12 | title: 'Button', 13 | } 14 | export const Default = () => { 15 | return 16 | } 17 | export const User = () => ( 18 | }> 19 | ) 20 | 21 | export const Cart = () => ( 22 | } 25 | badge={} 26 | > 27 | ) 28 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import TimesIcon from '../Icons/TimesIcon' 4 | 5 | import styles from './Modal.module.css' 6 | interface Props { 7 | visible: boolean 8 | children: React.ReactNode 9 | onClose?: () => any 10 | } 11 | 12 | const Modal = ({ visible, children, onClose }: Props) => { 13 | if (!visible || !process.browser) return null 14 | return ReactDOM.createPortal( 15 |
16 |
17 | 20 | {children} 21 |
22 |
, 23 | document?.getElementById('__next') as HTMLElement, 24 | ) 25 | } 26 | 27 | export default Modal 28 | -------------------------------------------------------------------------------- /frontend/cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import 'cypress' 2 | 3 | // *********************************************************** 4 | // This example plugins/index.js can be used to load plugins 5 | // 6 | // You can change the location of this file or turn off loading 7 | // the plugins file with the 'pluginsFile' configuration option. 8 | // 9 | // You can read more here: 10 | // https://on.cypress.io/plugins-guide 11 | // *********************************************************** 12 | 13 | // This function is called when a project is opened or re-opened (e.g. due to 14 | // the project's config changing) 15 | 16 | /** 17 | * @type {Cypress.PluginConfig} 18 | */ 19 | 20 | module.exports = (on: any, config: any) => { 21 | // `on` is used to hook into various events Cypress emits 22 | // `config` is the resolved Cypress config 23 | } 24 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, ValidationPipe } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthCredentialsDto } from './dto/auth-credentials-dto'; 4 | 5 | @Controller('auth') 6 | export class AuthController { 7 | constructor(private readonly authService: AuthService) {} 8 | 9 | @Post('/register') 10 | async register( 11 | @Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto, 12 | ): Promise<{ accessToken: string }> { 13 | return await this.authService.register(authCredentialsDto); 14 | } 15 | 16 | @Post('/login') 17 | async login( 18 | @Body('password') password: string, 19 | @Body('email') email: string, 20 | ): Promise<{ accessToken: string }> { 21 | return await this.authService.login(password, email); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { addDecorator } from '@storybook/react' 3 | 4 | import Router from 'next/router' 5 | import { RouterContext } from 'next/dist/next-server/lib/router-context' 6 | 7 | export const parameters = { 8 | actions: { argTypesRegex: '^on[A-Z].*' }, 9 | } 10 | 11 | import '../styles/app.css' 12 | import './storybook.css' 13 | 14 | addDecorator(Story => { 15 | Router.router = { 16 | route: '/', 17 | pathname: '/', 18 | query: {}, 19 | asPath: '/', 20 | push: () => {}, 21 | prefetch: () => new Promise((resolve, reject) => {}), 22 | } 23 | return ( 24 | 25 |
26 | 27 |
28 |
29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /frontend/src/components/SearchBar/SearchBar.module.css: -------------------------------------------------------------------------------- 1 | .form { 2 | background-color: var(--c-bg-secondary); 3 | 4 | width: 400px; 5 | padding: 0.5rem 0.825rem; 6 | 7 | border-radius: 8px; 8 | 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | 13 | box-shadow: 0px 1px 2px var(--shadow); 14 | border: 2px solid transparent; 15 | &:focus-within { 16 | border: 2px solid var(--c-primary-light); 17 | } 18 | } 19 | 20 | .input { 21 | outline: none; 22 | width: 100%; 23 | height: 100%; 24 | background: transparent; 25 | 26 | &::-webkit-search-decoration, 27 | &::-webkit-search-cancel-button, 28 | &::-webkit-search-results-button, 29 | &::-webkit-search-results-decoration { 30 | display: none; 31 | } 32 | &::placeholder { 33 | color: var(--c-text-secondary); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/Modal.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | height: 100%; 6 | width: 100%; 7 | background: rgba(0, 0, 0, 0.25); 8 | z-index: 99; 9 | 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .content { 16 | position: relative; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | width: 50%; 21 | min-height: 200px; 22 | padding: 2rem; 23 | background: white; 24 | border-radius: 8px; 25 | box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); 26 | } 27 | 28 | .close { 29 | cursor: pointer; 30 | appearance: none; 31 | background-color: transparent; 32 | position: absolute; 33 | right: 8px; 34 | top: 8px; 35 | 36 | & > svg { 37 | color: #f56565; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/Card/Card.stories.tsx: -------------------------------------------------------------------------------- 1 | import Card from './Card' 2 | import CardList from './CardList' 3 | 4 | export default { 5 | component: Card, 6 | title: 'Card', 7 | } 8 | 9 | const mockFunction = async (...props: any) => {} 10 | 11 | export const Default = () => { 12 | return ( 13 |
14 | mockFunction()} 16 | removeOneFromCart={async () => mockFunction()} 17 | isInCart={true} 18 | id="1" 19 | image="https://m.media-amazon.com/images/I/41FSyBId9TL.jpg" 20 | name={`Asus ROG Mothership GZ700 17.3\" 1920 x 1080 144 Hz Core i9-9980HK 2.4 GHz 64 GB Memory 1.5 TB NVME SSD Storage Laptop`} 21 | price="6499" 22 | > 23 |
24 | ) 25 | } 26 | 27 | export const List = () => { 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/components/Button/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from './IconButton.module.css' 5 | 6 | interface Props { 7 | text?: string 8 | bgColor?: string 9 | badge?: JSX.Element 10 | icon?: JSX.Element 11 | children?: React.ReactNode 12 | onClick?: (event: React.MouseEvent) => void 13 | } 14 | const IconButton: React.FC = ({ onClick, icon, badge, bgColor = 'bg-primary', text }: Props) => { 15 | return ( 16 | 27 | ) 28 | } 29 | 30 | export default IconButton 31 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/Checkbox.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withKnobs, number } from '@storybook/addon-knobs' 3 | import Checkbox from './Checkbox' 4 | import CheckboxList from './CheckboxList' 5 | 6 | export default { 7 | component: Checkbox, 8 | title: 'Checkbox', 9 | decorators: [withKnobs], 10 | } 11 | 12 | export const Default = () => { 13 | return 14 | } 15 | 16 | export const List = () => { 17 | return 18 | } 19 | 20 | function getData() { 21 | return { 22 | Asus: 39, 23 | Acer: 39, 24 | Razer: 14, 25 | MSI: 41, 26 | HP: 18, 27 | Apple: 10, 28 | Gigabyte: 5, 29 | Lenovo: 30, 30 | Microsoft: 24, 31 | Dell: 9, 32 | Aorus: 3, 33 | Samsung: 6, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './Button.module.css' 3 | import cx from 'classnames' 4 | 5 | interface Props { 6 | children?: React.ReactNode 7 | className?: string | null 8 | onClick?: (event: React.MouseEvent) => void 9 | type?: 'button' | 'submit' | 'reset' | undefined 10 | disabled?: boolean 11 | style?: React.CSSProperties | undefined 12 | variant?: 'ghost' | 'primary' 13 | } 14 | const Button = ({ variant = 'ghost', children, className, type = 'button', onClick, disabled, style }: Props) => { 15 | return ( 16 | 25 | ) 26 | } 27 | 28 | export default Button 29 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "test-utils": ["./test-utils"] 19 | } 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx", 25 | "../types.d.ts", 26 | "../next-env.d.ts", 27 | "../**/*.stories.ts", 28 | "../**/*.stories.tsx", 29 | "test-utils.ts" 30 | ], 31 | "exclude": ["dist", "node_modules", "cypress", "./cypress/tsconfig.json", "e2e"] 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/UserIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const UserIcon = ({ iconWidth = '24', iconHeight = '24' }: Props) => { 10 | return ( 11 | 24 | ) 25 | } 26 | 27 | export default UserIcon 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDebounce.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export const useDebounce = (value: string, delay: number = 60) => { 4 | // State and setters for debounced value 5 | const [debouncedValue, setDebouncedValue] = useState(value) 6 | 7 | useEffect( 8 | () => { 9 | // Update debounced value after delay 10 | const handler = setTimeout(() => { 11 | setDebouncedValue(value) 12 | }, delay) 13 | 14 | // Cancel the timeout if value changes (also on delay change or unmount) 15 | // This is how we prevent debounced value from updating if value is changed ... 16 | // .. within the delay period. Timeout gets cleared and restarted. 17 | return () => { 18 | clearTimeout(handler) 19 | } 20 | }, 21 | [value, delay], // Only re-call effect if value or delay changes 22 | ) 23 | 24 | return debouncedValue 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './Sidebar.module.css' 4 | 5 | import Slider from '../Slider/Slider' 6 | import CheckboxList from '../CheckBox/CheckboxList' 7 | import Dropdown from '../Dropdown/Dropdown' 8 | 9 | interface Props { 10 | filters: Record 11 | } 12 | 13 | const Sidebar = ({ filters }: Props) => { 14 | return ( 15 |
16 | 17 | {filters?.filterOrder?.map((key: string) => { 18 | if (filters?.sliders.includes(key)) { 19 | return 20 | } else { 21 | return 22 | } 23 | })} 24 |
25 | ) 26 | } 27 | 28 | export default Sidebar 29 | -------------------------------------------------------------------------------- /frontend/src/components/Spinner/Spinner.module.css: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | from { 3 | -webkit-transform: rotate(0deg); 4 | transform: rotate(0deg); 5 | } 6 | to { 7 | -webkit-transform: rotate(360deg); 8 | transform: rotate(360deg); 9 | } 10 | } 11 | .spinner { 12 | font-size: 10px; 13 | border-top: 0.5rem solid rgba(29, 161, 242, 0.2); 14 | border-right: 0.5rem solid rgba(29, 161, 242, 0.2); 15 | border-bottom: 0.5rem solid rgba(29, 161, 242, 0.2); 16 | border-left: 0.5rem solid #1da1f2; 17 | -webkit-transform: translateZ(0); 18 | -ms-transform: translateZ(0); 19 | transform: translateZ(0); 20 | animation: rotate 1.1s infinite linear; 21 | border-radius: 50%; 22 | width: 5em; 23 | height: 5em; 24 | margin: 0 auto; 25 | 26 | margin-top: 10%; 27 | margin-bottom: 100%; 28 | } 29 | .spinner:after { 30 | border-radius: 50%; 31 | width: 5em; 32 | height: 5em; 33 | } 34 | -------------------------------------------------------------------------------- /server/src/product/interfaces/product.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document, Types } from 'mongoose'; 2 | 3 | export interface Product extends Document { 4 | Model: string; 5 | Part: string; 6 | Seller: Types.ObjectId; 7 | SellerName: string; 8 | 'Screen Size': string; 9 | 'Screen Panel Type': string; 10 | Resolution: string; 11 | 'Refresh Rate': string; 12 | Dimensions: string; 13 | Weight: number; 14 | 'CPU Core Count': number; 15 | 'CPU Core Clock': string; 16 | 'CPU Boost Clock': string; 17 | Memory: number; 18 | CPU: string; 19 | 'CPU Microarchitecture': string; 20 | 'SSD Storage': string; 21 | 'SSD Type': string; 22 | Storage: string; 23 | GPU: string; 24 | 'GPU Memory': number; 25 | 'Operating System': string; 26 | 'SD Card Reader': string; 27 | 'Front Facing Webcam': string; 28 | Images: []; 29 | Name: string; 30 | Price: number; 31 | Manufacturer: string; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const TrashIcon = ({ iconWidth = '24', iconHeight = '24' }: Props) => { 10 | return ( 11 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default TrashIcon 26 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserController } from './user.controller'; 3 | import { UserService } from './user.service'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { userSchema } from './user.schema'; 6 | import { AuthModule } from 'src/auth/auth.module'; 7 | import { ProductModule } from 'src/product/product.module'; 8 | import { productSchema } from 'src/product/product.schema'; 9 | import { JwtModule } from '@nestjs/jwt'; 10 | 11 | @Module({ 12 | imports: [ 13 | ProductModule, 14 | MongooseModule.forFeature([ 15 | { name: 'user', schema: userSchema }, 16 | { name: 'product', schema: productSchema }, 17 | ]), 18 | AuthModule, 19 | JwtModule.register({ 20 | secret: process.env.JWT_SECRET, 21 | }), 22 | ], 23 | controllers: [UserController], 24 | providers: [UserService], 25 | }) 26 | export class UserModule {} 27 | -------------------------------------------------------------------------------- /frontend/src/components/ProductListHeader/ProductListHeader.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | max-width: 850px; 3 | height: max-content; 4 | margin: 0 auto; 5 | margin-bottom: 1rem; 6 | padding: 0.5rem; 7 | 8 | border: 2px solid var(--c-border); 9 | border-radius: 8px; 10 | 11 | display: flex; 12 | justify-content: flex-start; 13 | align-items: center; 14 | flex-wrap: wrap; 15 | 16 | & > div { 17 | margin: 0.25rem 0.25rem; 18 | } 19 | .button { 20 | cursor: pointer; 21 | margin-left: 1rem; 22 | font-weight: 700; 23 | font-size: 0.75rem; 24 | 25 | padding: 0.5rem; 26 | border-radius: 4px; 27 | 28 | color: var(--c-primary); 29 | background: var(--c-bg-secondary); 30 | 31 | transition: border var(--a-60-cubic); 32 | border: 2px solid var(--c-bg-secondary); 33 | &:hover { 34 | color: var(--c-text-on-p); 35 | background: var(--c-primary-light); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import cx from 'classnames' 4 | import styles from './logo.module.css' 5 | import { useFilterContext } from 'src/context/FilterContext/FilterContext' 6 | import { useRouter } from 'next/router' 7 | 8 | interface Props { 9 | className?: string 10 | } 11 | const Logo: React.FC = ({ className }: Props) => { 12 | const router = useRouter() 13 | const { filterDispatch, setPagination } = useFilterContext() 14 | 15 | const onClickHandler = () => { 16 | router.push('/').then(() => { 17 | setPagination(null) 18 | filterDispatch({ 19 | type: 'delete-all', 20 | payload: { 21 | category: '', 22 | value: '', 23 | }, 24 | }) 25 | }) 26 | } 27 | return ( 28 |

29 | Computer Store 30 |

31 | ) 32 | } 33 | 34 | export default Logo 35 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const SearchIcon = ({ iconWidth = '24', iconHeight = '24' }) => { 10 | return ( 11 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default SearchIcon 27 | -------------------------------------------------------------------------------- /frontend/pages/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import styles from './index.module.css' 4 | import AuthForm from '../../src/components/Form/AuthForm' 5 | import Button from 'src/components/Button/Button' 6 | 7 | const Login = () => { 8 | const [activePage, setActivePage] = useState<'Log in' | 'Sign up'>('Log in') 9 | return ( 10 |
11 |
12 | 19 | 22 |
23 | 24 |
25 | ) 26 | } 27 | 28 | export default Login 29 | -------------------------------------------------------------------------------- /frontend/src/components/Dropdown/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useFilterContext } from 'src/context/FilterContext/FilterContext' 3 | import styles from './Dropdown.module.css' 4 | 5 | const Dropdown = () => { 6 | const { filterDispatch } = useFilterContext() 7 | const handleChangeSelect = (event: React.ChangeEvent) => { 8 | filterDispatch({ 9 | type: 'toggle', 10 | payload: { 11 | category: 'sort', 12 | value: event.currentTarget.value, 13 | }, 14 | }) 15 | } 16 | return ( 17 | 28 | ) 29 | } 30 | 31 | export default Dropdown 32 | -------------------------------------------------------------------------------- /server/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { User } from 'src/user/interfaces/user.interface'; 5 | import { Model } from 'mongoose'; 6 | import { InjectModel } from '@nestjs/mongoose'; 7 | import { JwtPayload } from './interfaces/jwt-payload.interface'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor(@InjectModel('user') private readonly userModel: Model) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | ignoreExpiration: false, 15 | secretOrKey: process.env.JWT_SECRET, 16 | }); 17 | } 18 | 19 | async validate(payload: JwtPayload): Promise { 20 | const user = await this.userModel.findOne({ email: payload.email }); 21 | if (!user) { 22 | throw new UnauthorizedException(); 23 | } 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/CartIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const CartIcon = ({ iconWidth = '24', iconHeight = '24' }: Props) => { 10 | return ( 11 | 25 | ) 26 | } 27 | 28 | export default CartIcon 29 | -------------------------------------------------------------------------------- /frontend/src/components/Chip/Chip.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: max-content; 3 | height: 0.75rem; 4 | background: var(--c-bg-secondary); 5 | margin: 0 0.25rem; 6 | 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | 11 | font-size: 0.75rem; 12 | padding: 0.725rem 0.5rem; 13 | padding-right: 1rem; 14 | border-radius: 1rem; 15 | 16 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 17 | } 18 | .button { 19 | appearance: none; 20 | cursor: pointer; 21 | 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | 26 | background: var(--c-bg-primary); 27 | width: 0.5rem; 28 | height: 0.5rem; 29 | padding: 0.5rem; 30 | 31 | margin-right: 0.5rem; 32 | border-radius: 50%; 33 | border: 1px solid rgba(0, 0, 0, 0.05); 34 | 35 | &:hover { 36 | background: var(--c-primary-light); 37 | color: var(--c-text-on-p); 38 | } 39 | } 40 | .text { 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | .category { 45 | color: var(--c-text-secondary); 46 | } 47 | .value { 48 | color: var(--c-text-dark); 49 | } 50 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { userSchema } from '../user/user.schema'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { UserService } from 'src/user/user.service'; 7 | import { JwtModule } from '@nestjs/jwt'; 8 | import { PassportModule } from '@nestjs/passport'; 9 | import { JwtStrategy } from './jwt.strategy'; 10 | import { ConfigModule } from '@nestjs/config'; 11 | import { productSchema } from 'src/product/product.schema'; 12 | 13 | @Module({ 14 | imports: [ 15 | ConfigModule.forRoot(), 16 | MongooseModule.forFeature([ 17 | { name: 'user', schema: userSchema }, 18 | { name: 'product', schema: productSchema }, 19 | ]), 20 | PassportModule.register({ defaultStrategy: 'jwt' }), 21 | JwtModule.register({ 22 | secret: process.env.JWT_SECRET, 23 | }), 24 | ], 25 | controllers: [AuthController], 26 | providers: [AuthService, UserService, JwtStrategy], 27 | exports: [JwtStrategy, PassportModule], 28 | }) 29 | export class AuthModule {} 30 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/Checkbox.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import user from '@testing-library/user-event' 3 | import { render } from '@testing-library/react' 4 | import Checkbox from './Checkbox' 5 | import { FilterContextProvider } from 'src/context/FilterContext/FilterContext' 6 | 7 | jest.mock('next/router', () => ({ 8 | useRouter: jest.fn().mockImplementation(() => ({ 9 | push: jest.fn(() => null), 10 | })), 11 | })) 12 | 13 | test('checkbox renders properly, state works', () => { 14 | const testData = { value: 'test', count: 5, category: 'test' } 15 | const { getByLabelText, getByText } = render( 16 | 17 | 18 | , 19 | ) 20 | 21 | const checkbox = getByLabelText(/test/i) as HTMLInputElement 22 | expect(checkbox.getAttribute('aria-checked')).toBe('false') 23 | 24 | expect(checkbox.name).toBe(testData.value) 25 | expect(getByText(/test/i)).toHaveTextContent(testData.value) 26 | expect(getByText(/5/i)).toHaveTextContent(testData.count.toString()) 27 | }) 28 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/TimesIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const TimesIcon = ({ iconWidth = '24', iconHeight = '24' }: Props) => { 10 | return ( 11 | 28 | ) 29 | } 30 | 31 | export default TimesIcon 32 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar/SidebarSkeleton.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: var(--c-bg-secondary); 3 | border-radius: 8px; 4 | border: 2px solid var(--c-border); 5 | box-shadow: 0px 1px 2px var(--shadow); 6 | height: 100%; 7 | display: flex; 8 | flex-direction: column; 9 | padding: 1rem 0.5rem; 10 | } 11 | 12 | .slider { 13 | display: flex; 14 | flex-direction: column; 15 | margin: 1rem 0; 16 | 17 | & > div { 18 | margin-bottom: 1rem; 19 | } 20 | & > div > div { 21 | margin-bottom: 1rem; 22 | } 23 | } 24 | .checkbox { 25 | & > div { 26 | margin-bottom: 1rem; 27 | } 28 | } 29 | .line { 30 | background-color: #f6f7f8; 31 | color: #f6f7f8; 32 | position: relative; 33 | overflow: hidden; 34 | border-radius: 4px; 35 | } 36 | 37 | @keyframes shimmer { 38 | 100% { 39 | transform: translateX(100%); 40 | } 41 | } 42 | .line::after { 43 | position: absolute; 44 | top: 0; 45 | right: 0; 46 | bottom: 0; 47 | left: 0; 48 | transform: translateX(-100%); 49 | 50 | background: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%); 51 | animation: shimmer 1s infinite; 52 | content: ''; 53 | } 54 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import '../styles/app.css' 3 | import type { AppProps } from 'next/app' 4 | import SiteLayout from '../src/components/Layout/SiteLayout' 5 | import { ReactQueryDevtools } from 'react-query-devtools' 6 | import { ReactQueryConfigProvider } from 'react-query' 7 | import { UserContextProvider } from 'src/context/UserContext/UserContext' 8 | import { useEffect } from 'react' 9 | 10 | const queryConfig = { queries: { refetchOnWindowFocus: false } } 11 | 12 | export default function App({ Component, pageProps, router }: AppProps) { 13 | return ( 14 | 15 | 16 | 17 | Computer Store 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/Icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './icon.module.css' 4 | 5 | interface Props { 6 | iconWidth?: string 7 | iconHeight?: string 8 | } 9 | const CheckIcon = ({ iconWidth = '24', iconHeight = '24' }: Props) => { 10 | return ( 11 |
12 | 29 |
30 | ) 31 | } 32 | 33 | export default CheckIcon 34 | -------------------------------------------------------------------------------- /frontend/styles/app.css: -------------------------------------------------------------------------------- 1 | @import 'variables.css'; 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | font: inherit; 8 | } 9 | 10 | html { 11 | font-size: 16px; 12 | line-height: 1; 13 | } 14 | 15 | body { 16 | -webkit-font-smoothing: subpixel-antialiased; 17 | font-kerning: normal; 18 | text-rendering: optimizeLegibility; 19 | font-size: 1rem; 20 | 21 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 22 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 23 | 24 | background-color: var(--c-bg-primary); 25 | color: var(--c-text); 26 | } 27 | 28 | #__next { 29 | width: 100%; 30 | } 31 | 32 | input[type='checkbox'] { 33 | cursor: pointer; 34 | } 35 | label { 36 | cursor: pointer; 37 | user-select: none; 38 | } 39 | a { 40 | color: unset; 41 | text-decoration: none; 42 | appearance: none; 43 | cursor: pointer; 44 | } 45 | a:focus { 46 | outline: none; 47 | } 48 | a:active, 49 | a:hover { 50 | outline: 0; 51 | } 52 | 53 | dialog { 54 | display: flex; 55 | justify-content: center; 56 | align-items: center; 57 | } 58 | dialog::backdrop { 59 | background: rgba(0, 0, 0, 0.25); 60 | } 61 | -------------------------------------------------------------------------------- /server/src/user/user.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import * as bcrypt from 'bcrypt'; 3 | import { productSchema } from 'src/product/product.schema'; 4 | 5 | export const userSchema = new mongoose.Schema({ 6 | name: { 7 | type: String, 8 | required: true, 9 | }, 10 | password: { 11 | type: String, 12 | required: true, 13 | select: false, 14 | }, 15 | email: { 16 | type: String, 17 | required: true, 18 | unique: true, 19 | }, 20 | addresses: [ 21 | { 22 | addressName: { 23 | type: String, 24 | }, 25 | city: { 26 | type: String, 27 | }, 28 | country: { 29 | type: String, 30 | }, 31 | address: { 32 | type: String, 33 | }, 34 | }, 35 | ], 36 | phone: { 37 | type: Number, 38 | }, 39 | shoppingCart: [{ type: mongoose.Schema.Types.ObjectId, ref: 'product' }], 40 | 41 | orders: { type: [productSchema], select: false }, 42 | }); 43 | 44 | userSchema.methods.validatePassword = async function ( 45 | enteredPassword: string, 46 | hashedPassword: string, 47 | ): Promise { 48 | return await bcrypt.compare(enteredPassword, hashedPassword); 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/components/Button/IconButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | color: var(--c-text); 3 | cursor: pointer; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | 8 | height: 50px; 9 | padding: 0.5rem 1rem; 10 | 11 | outline: none; 12 | border: 1px solid transparent; 13 | border-radius: 8px; 14 | 15 | transition: background-color 90ms ease-in; 16 | &:focus { 17 | border: 1px solid var(--c-primary-light); 18 | } 19 | &:hover { 20 | border: 1px solid transparent; 21 | background-color: var(--c-primary-light); 22 | color: var(--c-text-on-p); 23 | & > svg, 24 | div > svg { 25 | fill: var(--c-text-on-p); 26 | } 27 | } 28 | 29 | text-align: center; 30 | font-size: 0.825rem; 31 | } 32 | 33 | .text { 34 | margin-left: 4px; 35 | text-align: start; 36 | display: block; 37 | display: -webkit-box; 38 | -webkit-line-clamp: 2; 39 | max-width: 170px; 40 | text-align: left; 41 | overflow: hidden; 42 | -webkit-box-orient: vertical; 43 | } 44 | 45 | .bg-primary { 46 | background: var(--c-bg-primary); 47 | } 48 | 49 | .bg-secondary { 50 | background: var(--c-bg-secondary); 51 | border: 1px solid rgba(0, 0, 0, 0.1); 52 | } 53 | -------------------------------------------------------------------------------- /server/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | 3 | import { AuthCredentialsDto } from './dto/auth-credentials-dto'; 4 | import { UserService } from 'src/user/user.service'; 5 | import { JwtService } from '@nestjs/jwt'; 6 | import { JwtPayload } from './interfaces/jwt-payload.interface'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor( 11 | private readonly userService: UserService, 12 | private readonly jwtService: JwtService, 13 | ) {} 14 | 15 | async register( 16 | authCredentialsDto: AuthCredentialsDto, 17 | ): Promise<{ accessToken: string }> { 18 | return await this.userService.register(authCredentialsDto); 19 | } 20 | 21 | async login( 22 | password: string, 23 | email: string, 24 | ): Promise<{ accessToken: string }> { 25 | const user = await this.userService.validateUserPassword(password, email); 26 | 27 | if (!user) { 28 | throw new UnauthorizedException('Email or Password is invalid'); 29 | } 30 | 31 | const payload: JwtPayload = { email: user.email, id: user.id }; 32 | 33 | const accessToken = this.jwtService.sign(payload); 34 | 35 | return { accessToken }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/Card/CardList.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: grid; 3 | grid-template-columns: repeat(auto-fit, 280px); 4 | grid-gap: 1.5rem; 5 | 6 | justify-content: center; 7 | } 8 | 9 | .pagination_container { 10 | flex: 1; 11 | flex-basis: 100%; 12 | width: fit-content; 13 | display: flex; 14 | flex-wrap: wrap; 15 | justify-content: flex-start; 16 | align-items: center; 17 | margin: 0 auto; 18 | margin-top: 2rem; 19 | margin-bottom: auto; 20 | 21 | & > button { 22 | cursor: pointer; 23 | border: 1px solid var(--c-border); 24 | 25 | width: 2rem; 26 | height: 2rem; 27 | text-align: center; 28 | 29 | margin: 0 1rem; 30 | margin-top: 1rem; 31 | 32 | font-weight: 600; 33 | border-radius: 0.5rem; 34 | transition: box-shadow, border-color 100ms ease-in; 35 | &:hover { 36 | border-color: var(--c-border-dark); 37 | box-shadow: 0 1px 2px 0 rgba(60, 64, 67, 0.302), 0 1px 3px 1px rgba(60, 64, 67, 0.149); 38 | } 39 | } 40 | } 41 | .button_not_selected { 42 | background: var(--c-bg-secondary); 43 | color: var(--c-text-on-s) !important; 44 | } 45 | .button_selected { 46 | background: var(--c-primary) !important; 47 | color: var(--c-text-on-p) !important; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import styles from './Checkbox.module.css' 3 | import { useFilterContext } from 'src/context/FilterContext/FilterContext' 4 | 5 | interface Props { 6 | value: string 7 | count: number 8 | category: string 9 | checked: boolean 10 | } 11 | const Checkbox: React.FC = ({ value, count, category, checked }: Props) => { 12 | const { filterDispatch } = useFilterContext() 13 | 14 | const onChangeHandler = () => { 15 | if (checked) { 16 | filterDispatch({ type: 'delete', payload: { category, value } }) 17 | } else { 18 | filterDispatch({ type: 'add', payload: { category, value } }) 19 | } 20 | } 21 | 22 | return ( 23 | 39 | ) 40 | } 41 | 42 | export default Checkbox 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Computer Store 5 |

6 | 7 | > 🚨 This is not a commercial project. I did it to improve my knowledge ❤ 8 | > 9 | 10 | 🚀 [Live Demo](https://computer-store.vercel.app/) 11 | 12 | # Tech Stack 13 | Frontend 14 | - React / Next.js 15 | - Typescript 16 | - PostCSS 17 | - Storybook 18 | 19 | Backend 20 | - Nest.js / Express 21 | - Typescript 22 | - MongoDB / Mongoose 23 | - JSON Web Tokens 24 | 25 | 26 | 27 | # How to use 28 | ### Quick Start 29 | ```bash 30 | clone repo 31 | cd frontend && yarn dev 32 | cd server && yarn start:dev 33 | ``` 34 | 35 | This project uses atlas as database service and uses JWT for auth. 36 | To use these systems, simply enter the necessary variables in the .env file. 37 | 38 | 39 | How to use MongoDB Atlas -> https://docs.atlas.mongodb.com/getting-started 40 | 41 | 42 | ` 43 | .env file location = computer-store/server/.env 44 | ` 45 | ``` 46 | MONGODB_URI=mongodb+srv://:@devcampercluster.oqatm.mongodb.net/?retryWrites=true&w=majority 47 | 48 | JWT_SECRET=any text 49 | 50 | ``` 51 | 52 | # Backend API Documentation 53 | Once the application is running you can visit http://localhost:3001/api to see the Swagger interface. 54 | 55 | -------------------------------------------------------------------------------- /frontend/src/components/Card/CardSkeleton.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | padding: 1rem; 4 | padding-top: 1rem; 5 | 6 | background: var(--c-bg-secondary); 7 | border-radius: 8px; 8 | box-shadow: 0 1px 2px var(--shadow); 9 | 10 | & > *, 11 | & > * > * { 12 | border-radius: 8px; 13 | } 14 | } 15 | 16 | .media { 17 | width: 100%; 18 | height: 244px; 19 | } 20 | 21 | .text { 22 | height: 1rem; 23 | margin-top: 1rem; 24 | } 25 | 26 | .text_container { 27 | display: flex; 28 | flex-direction: row; 29 | justify-content: space-between; 30 | & > div { 31 | width: 5rem; 32 | } 33 | } 34 | 35 | .shimmer { 36 | animation-duration: 2.2s; 37 | animation-fill-mode: forwards; 38 | animation-iteration-count: infinite; 39 | animation-name: shimmer; 40 | animation-timing-function: linear; 41 | background: #ddd; 42 | background: linear-gradient(to right, #f6f6f6 8%, #f0f0f0 18%, #f6f6f6 33%); 43 | background-size: 1200px 100%; 44 | } 45 | 46 | @-webkit-keyframes shimmer { 47 | 0% { 48 | background-position: -100% 0; 49 | } 50 | 100% { 51 | background-position: 100% 0; 52 | } 53 | } 54 | 55 | @keyframes shimmer { 56 | 0% { 57 | background-position: -1200px 0; 58 | } 59 | 100% { 60 | background-position: 1200px 0; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /frontend/src/utils/auth-form.dto.ts: -------------------------------------------------------------------------------- 1 | export const AuthDto = { 2 | 'Log in': { 3 | email: { 4 | required: 'Email cannot be empty', 5 | }, 6 | password: { 7 | required: 'Password cannot be empty', 8 | minLength: { value: 8, message: 'Password must be at least 8 characters' }, 9 | maxLength: { value: 20, message: 'Password can be maximum of 20 characters' }, 10 | }, 11 | }, 12 | 'Sign up': { 13 | name: { 14 | required: 'Name cannot be empty', 15 | maxLength: { value: 30, message: 'Name can be maximum of 30 characters' }, 16 | }, 17 | email: { 18 | required: 'Email cannot be empty', 19 | validate: (value: string) => { 20 | return ( 21 | [/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/].every(pattern => pattern.test(value)) || 22 | 'Email is not valid' 23 | ) 24 | }, 25 | }, 26 | password: { 27 | required: 'Password cannot be empty', 28 | minLength: { value: 8, message: 'Password must be at least 8 characters' }, 29 | maxLength: { value: 20, message: 'Password can be maximum of 20 characters' }, 30 | validate: (value: string) => { 31 | return ( 32 | [/(?:(?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/].every(pattern => pattern.test(value)) || 33 | 'Password is weak' 34 | ) 35 | }, 36 | }, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /server/src/product/pipes/product.filters-pipe.ts: -------------------------------------------------------------------------------- 1 | import { PipeTransform } from '@nestjs/common'; 2 | 3 | export class ProductFiltersPipe implements PipeTransform { 4 | readonly shouldTransformToMinMax = [ 5 | 'Price', 6 | 'Weight', 7 | 'CPU Core Count', 8 | 'CPU Core Clock', 9 | 'CPU Boost Clock', 10 | 'Memory', 11 | ]; 12 | readonly shouldTransformToInArray = [ 13 | 'Manufacturer', 14 | 'Screen Size', 15 | 'Screen Panel Type', 16 | 'Resolution', 17 | 'Refresh Rate', 18 | 'CPU', 19 | 'GPU', 20 | 'Operating System', 21 | 'SD Card Reader', 22 | ]; 23 | 24 | transform(query: Record): Record { 25 | const find = {}; 26 | const sort = query.sort; 27 | 28 | const search = query.search?.length > 3 ? query.search : null; 29 | 30 | const page = parseInt(query.page) || 1; 31 | 32 | for (const [key, value] of Object.entries(query)) { 33 | if (this.shouldTransformToInArray.includes(key)) { 34 | const values = value.split(','); 35 | find[key] = { $in: values }; 36 | } 37 | if (this.shouldTransformToMinMax.includes(key)) { 38 | const values = value.split(','); 39 | 40 | find[key] = { 41 | $gte: values[0] || Number.MIN_SAFE_INTEGER, 42 | $lte: values[1] || Number.MAX_SAFE_INTEGER, 43 | }; 44 | } 45 | } 46 | 47 | return { find, sort, page, search }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/src/product/product.field-values.ts: -------------------------------------------------------------------------------- 1 | const manufacturer = [ 2 | 'Casper', 3 | 'Lenovo', 4 | 'Dell', 5 | 'MSI', 6 | 'Acer', 7 | 'Apple', 8 | 'Toshiba', 9 | 'Monster', 10 | 'Huawei', 11 | 'Microsoft', 12 | 'Gigabyte', 13 | 'Aorus', 14 | 'Razer', 15 | 'Samsung', 16 | 'Xiaomi', 17 | 'Asus', 18 | ]; 19 | 20 | const resolution = [ 21 | '1280 x 800', 22 | '1440 x 900', 23 | '1680 x 1050', 24 | '1920 x 1200', 25 | '2560 x 1600', 26 | '1024 x 576', 27 | '1152 x 648', 28 | '1280 x 720', 29 | '1366 x 768', 30 | '1600 x 900', 31 | '1920 x 1080', 32 | '2560 x 1440', 33 | '3840 x 2160', 34 | '7680 x 4320', 35 | '5120 x 2880', 36 | '3840 x 2160', 37 | '2880 x 1800', 38 | '2560 x 1600', 39 | '2880 x 1800', 40 | '2732 x 1536', 41 | ]; 42 | const screenPanelType = [ 43 | 'IPS', 44 | 'VA', 45 | 'OLED', 46 | 'TN', 47 | 'PLS', 48 | 'S-IPS', 49 | 'H-IPS', 50 | 'e-IPS', 51 | 'P-IPS', 52 | 'AHVA', 53 | ]; 54 | const refreshRate = [60, 75, 90, 120, 144, 240, 320, 160, 165, 180]; 55 | const memory = [1, 2, 3, 4, 6, 8, 12, 14, 16, 18, 20, 24, 32, 64, 128, 512]; 56 | const cpuCoreCount = [1, 2, 3, 4, 6, 8, 12, 14, 16, 18, 20, 24, 32, 64, 128]; 57 | 58 | const productFieldValues = { 59 | memory: memory, 60 | cpuCoreCount: cpuCoreCount, 61 | resolution: resolution, 62 | manufacturer: manufacturer, 63 | screenPanelType: screenPanelType, 64 | refreshRate: refreshRate, 65 | }; 66 | 67 | export default productFieldValues; 68 | -------------------------------------------------------------------------------- /frontend/src/components/SearchBar/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | import cx from 'classnames' 4 | import styles from './SearchBar.module.css' 5 | import SearchIcon from '../Icons/SearchIcon' 6 | import { useDebounce } from '../../hooks/useDebounce' 7 | import { useFilterContext } from 'src/context/FilterContext/FilterContext' 8 | 9 | interface Props { 10 | className?: string 11 | } 12 | const SearchBar: React.FC = ({ className }: Props) => { 13 | const { filterDispatch } = useFilterContext() 14 | const [inputValue, setInputValue] = useState('') 15 | const debouncedValue = useDebounce(inputValue, 200) 16 | 17 | useEffect(() => { 18 | if (debouncedValue.length >= 4) { 19 | filterDispatch({ 20 | type: 'add-string', 21 | payload: { 22 | category: 'search', 23 | value: debouncedValue, 24 | }, 25 | }) 26 | } else { 27 | filterDispatch({ 28 | type: 'delete-string', 29 | payload: { 30 | category: 'search', 31 | value: debouncedValue, 32 | }, 33 | }) 34 | } 35 | }, [debouncedValue]) 36 | 37 | return ( 38 |
39 | setInputValue(e.currentTarget.value)} 45 | > 46 | 47 | 48 | ) 49 | } 50 | 51 | export default SearchBar 52 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/CheckboxList.module.css: -------------------------------------------------------------------------------- 1 | .checkboxListContainer { 2 | width: 100%; 3 | 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: flex-start; 8 | } 9 | .list { 10 | max-height: 200px; 11 | width: 100%; 12 | overflow: auto; 13 | 14 | &::-webkit-scrollbar { 15 | width: 4px; 16 | padding: 0; 17 | background-color: #fff; 18 | } 19 | &::-webkit-scrollbar-thumb { 20 | border-radius: 8px; 21 | background-color: #ccc; 22 | } 23 | &::-webkit-scrollbar-track { 24 | border-radius: 10px; 25 | background-color: #eee; 26 | } 27 | 28 | & > label { 29 | font-size: 0.875rem; 30 | margin-bottom: 1rem; 31 | } 32 | } 33 | 34 | .title { 35 | font-size: 0.875rem; 36 | font-weight: 700; 37 | color: var(--c-text); 38 | } 39 | 40 | .input { 41 | font-size: 0.875rem; 42 | height: 2rem; 43 | width: 100%; 44 | margin: 0.825rem 0; 45 | padding: 0.5rem 0.5rem; 46 | 47 | background-color: var(--c-bg-primary); 48 | border: 2px solid var(--c-bg-secondary); 49 | border-radius: 4px; 50 | outline: none; 51 | &:focus { 52 | border: 2px solid var(--c-primary-light); 53 | } 54 | 55 | &::-webkit-search-decoration, 56 | &::-webkit-search-cancel-button, 57 | &::-webkit-search-results-button, 58 | &::-webkit-search-results-decoration { 59 | display: none; 60 | } 61 | &::placeholder { 62 | color: var(--c-text-secondary); 63 | font-size: 12px; 64 | letter-spacing: 0.5px; 65 | } 66 | } 67 | 68 | .space { 69 | width: 100%; 70 | margin: 0.5rem 0; 71 | } 72 | -------------------------------------------------------------------------------- /frontend/src/components/Dropdown/Dropdown.module.css: -------------------------------------------------------------------------------- 1 | .select { 2 | appearance: none; 3 | display: block; 4 | box-sizing: border-box; 5 | 6 | width: 100%; 7 | padding: 0; 8 | margin: 0; 9 | padding-right: 2.5rem; 10 | padding: 0.5rem 0; 11 | outline: none; 12 | 13 | background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007CB2%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); 14 | background-repeat: no-repeat, repeat; 15 | background-position: right 8px top 50%, 0 0; 16 | background-size: 1rem auto, 100%; 17 | background-color: var(--c-bg); 18 | color: var(--c-text); 19 | line-height: 20px; 20 | font-size: 0.875rem; 21 | font-weight: 400; 22 | 23 | border-radius: 8px; 24 | 25 | &:hover, 26 | &:active { 27 | background-color: var(--c-bg-secondary); 28 | } 29 | 30 | border: 1px solid transparent; 31 | &:focus { 32 | border: 1px solid var(--c-primary-light); 33 | } 34 | 35 | &::-ms-expand { 36 | display: none; 37 | } 38 | } 39 | 40 | .option { 41 | display: block; 42 | font-size: 0.875rem; 43 | font-weight: 400; 44 | margin: 0.5rem; 45 | } 46 | 47 | .title { 48 | margin-bottom: 0 !important; 49 | font-size: 0.875rem; 50 | font-weight: 700; 51 | color: var(--c-text); 52 | } 53 | -------------------------------------------------------------------------------- /frontend/.storybook/post-css.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | webpackFinal: async (baseConfig, options) => { 5 | // Modify or replace config. Mutating the original reference object can cause unexpected bugs. 6 | const { module = {} } = baseConfig 7 | 8 | baseConfig.resolve.modules = [...(baseConfig.resolve.modules || []), path.resolve('./')] 9 | 10 | const newConfig = { 11 | ...baseConfig, 12 | module: { 13 | ...module, 14 | 15 | rules: [...(module.rules || [])], 16 | }, 17 | } 18 | 19 | // 20 | // CSS Modules 21 | // Many thanks to https://github.com/storybookjs/storybook/issues/6055#issuecomment-521046352 22 | // 23 | 24 | // First we prevent webpack from using Storybook CSS rules to process CSS modules 25 | newConfig.module.rules.find(rule => rule.test.toString() === '/\\.css$/').exclude = /\.module\.css$/ 26 | 27 | // Then we tell webpack what to do with CSS modules 28 | newConfig.module.rules.push({ 29 | test: /\.module\.css$/, 30 | include: path.resolve(__dirname, '../components'), 31 | use: [ 32 | 'style-loader', 33 | { 34 | loader: 'css-loader', 35 | options: { 36 | importLoaders: 1, 37 | modules: true, 38 | }, 39 | }, 40 | { 41 | loader: 'postcss-loader', 42 | options: { 43 | sourceMap: true, 44 | config: { 45 | path: './.storybook/', 46 | }, 47 | }, 48 | }, 49 | ], 50 | include: path.resolve(__dirname, '../'), 51 | }) 52 | 53 | return newConfig 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /frontend/pages/_home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styles from './index.module.css' 3 | import cx from 'classnames' 4 | import SidebarSkeleton from 'src/components/Sidebar/SidebarSkeleton' 5 | import Sidebar from 'src/components/Sidebar/Sidebar' 6 | import ProductListHeader from 'src/components/ProductListHeader/ProductListHeader' 7 | import CardList from 'src/components/Card/CardList' 8 | import { BASE_URL } from 'src/utils/api' 9 | 10 | const Home = () => { 11 | const [sidebar, setSideBar] = useState(false) 12 | const [filters, setFilters] = useState(null) 13 | 14 | useEffect(() => { 15 | const getFilters = async () => { 16 | try { 17 | const res = await fetch(`${BASE_URL}/product/filters`) 18 | const data = await res.json() 19 | setFilters(data) 20 | } catch (error) {} 21 | } 22 | getFilters() 23 | }, []) 24 | return ( 25 | <> 26 | 29 |
30 | {sidebar && ( 31 |
{ 34 | setSideBar(false) 35 | }} 36 | >
37 | )} 38 |
39 | {!filters ? : } 40 |
41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | ) 49 | } 50 | 51 | export default Home 52 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/Checkbox.module.css: -------------------------------------------------------------------------------- 1 | .label { 2 | font-size: 0.875rem; 3 | text-align: center; 4 | 5 | width: 100%; 6 | 7 | display: flex; 8 | justify-content: flex-start; 9 | align-items: center; 10 | 11 | transition: background-color var(--a-60-cubic); 12 | 13 | &:hover, 14 | &:focus-within { 15 | background-color: var(--c-bg-secondary); 16 | } 17 | } 18 | .input { 19 | appearance: none; 20 | height: 1rem; 21 | width: 1.125rem; 22 | 23 | background-color: var(--c-bg-primary); 24 | border: 2px solid var(--c-bg-secondary-dark); 25 | border-radius: 2px; 26 | 27 | outline: none; 28 | 29 | transition: border var(--a-60-cubic); 30 | 31 | &:hover, 32 | &:focus { 33 | border: 2px solid var(--c-bg-secondary-darker); 34 | 35 | + .text { 36 | color: black; 37 | } 38 | } 39 | 40 | &:checked { 41 | background-color: var(--c-primary); 42 | border: 2px solid var(--c-primary); 43 | } 44 | &:checked::after { 45 | content: ''; 46 | } 47 | } 48 | 49 | .input::after { 50 | display: inline-block; 51 | position: relative; 52 | top: -2px; 53 | right: -2px; 54 | 55 | height: 8px; 56 | width: 4px; 57 | transform: rotate(45deg); 58 | border-bottom: 3px solid var(--c-text-on-p); 59 | border-right: 3px solid var(--c-text-on-p); 60 | } 61 | 62 | .info { 63 | width: 100%; 64 | display: flex; 65 | flex-direction: row; 66 | justify-content: space-between; 67 | align-items: center; 68 | padding: 0 0.5rem; 69 | } 70 | .text { 71 | width: 100%; 72 | display: block; 73 | display: -webkit-box; 74 | -webkit-line-clamp: 1; 75 | text-align: left; 76 | overflow: hidden; 77 | -webkit-box-orient: vertical; 78 | } 79 | .count { 80 | font-size: 0.825rem; 81 | color: var(--c-text-secondary); 82 | opacity: 0.8; 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/components/Card/Card.module.css: -------------------------------------------------------------------------------- 1 | .item { 2 | cursor: pointer; 3 | position: relative; 4 | padding: 1rem; 5 | padding-top: 0; 6 | 7 | transition: border var(--a-60-cubic); 8 | border: 2px solid transparent; 9 | 10 | &:hover { 11 | border: 2px solid var(--c-primary-light); 12 | } 13 | background: var(--c-bg-secondary); 14 | border-radius: 8px; 15 | box-shadow: 0 1px 2px var(--shadow); 16 | } 17 | 18 | .container { 19 | width: 100%; 20 | height: 100%; 21 | 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: flex-start; 25 | align-items: center; 26 | 27 | & > img { 28 | flex: 1; 29 | width: 100%; 30 | object-fit: contain; 31 | object-position: center; 32 | } 33 | } 34 | 35 | .name { 36 | width: 100%; 37 | display: block; 38 | display: -webkit-box; 39 | -webkit-line-clamp: 2; 40 | text-align: left; 41 | overflow: hidden; 42 | -webkit-box-orient: vertical; 43 | 44 | margin-top: 1rem; 45 | } 46 | .footer { 47 | margin-top: 1rem; 48 | width: 100%; 49 | display: flex; 50 | flex-direction: row; 51 | justify-content: space-between; 52 | align-items: center; 53 | } 54 | .price { 55 | text-align: left; 56 | font-weight: 700; 57 | font-size: 1.125rem; 58 | } 59 | 60 | .button { 61 | cursor: pointer; 62 | font-weight: 700; 63 | font-size: 13px; 64 | padding: 0.5rem; 65 | border-radius: 4px; 66 | 67 | color: var(--c-text); 68 | background: var(--c-bg-secondary); 69 | 70 | transition: border var(--a-60-cubic); 71 | border: 2px solid var(--c-bg-secondary); 72 | &:hover { 73 | color: var(--c-text-on-p); 74 | background: var(--c-primary-light); 75 | } 76 | } 77 | 78 | .color_pink { 79 | color: #f56a79; 80 | &:hover { 81 | color: var(--c-text-on-p); 82 | background: #f56a79; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /frontend/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document' 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx: DocumentContext) { 5 | const initialProps = await Document.getInitialProps(ctx) 6 | return { ...initialProps } 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | 42 | export default MyDocument 43 | -------------------------------------------------------------------------------- /frontend/src/context/UserContext/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | _id: string 3 | shoppingCart: string[] 4 | name: string 5 | email: string 6 | addresses: [] 7 | } 8 | 9 | export interface Action { 10 | type: 'save' | 'delete' | 'update-cart' | 'delete-from-cart-one' | 'delete-from-cart-all' | 'save-cart' 11 | payload: User | string | string[] 12 | } 13 | export interface ActionCart { 14 | type: 'add-to-cart-one' | 'delete-from-cart-one' | 'delete-from-cart-all' 15 | payload: string 16 | } 17 | export interface IUserContext { 18 | userState: User | null 19 | accessToken: string | null 20 | dispatchUserState: React.Dispatch 21 | setAccessToken: React.Dispatch> 22 | cartInLocalStorage: string 23 | dispatchCartInLocalStorage: React.Dispatch 24 | addOneToCart: (id: string) => Promise 25 | removeOneFromCart: (id: string) => Promise 26 | removeAllFromCart: () => Promise 27 | } 28 | 29 | export interface Product { 30 | _id: string 31 | Manufacturer: string 32 | Model: string 33 | Part: string 34 | Type: string 35 | 'Screen Size': string 36 | 'Screen Panel Type': string 37 | Resolution: string 38 | 'Refresh Rate': string 39 | Dimensions: string 40 | Weight: number 41 | 'CPU Core Count': number 42 | 'CPU Core Clock': number 43 | 'CPU Boost Clock': number 44 | Memory: number 45 | CPU: string 46 | 'CPU Microarchitecture': string 47 | 'SSD Storage': string 48 | 'SSD Type': string 49 | Storage: string 50 | GPU: string 51 | 'GPU Memory': number 52 | 'Operating System': string 53 | 'SD Card Reader': string 54 | 'Front Facing Webcam': string 55 | Images: string[] 56 | Name: string 57 | Price: number 58 | SellerName: string 59 | Seller: string 60 | } 61 | 62 | export interface ProductPreview { 63 | _id: string 64 | Images: string[] 65 | Name: string 66 | Price: number 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/CheckboxList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import styles from './CheckboxList.module.css' 4 | import Checkbox from './Checkbox' 5 | import { useFilterContext } from 'src/context/FilterContext/FilterContext' 6 | 7 | interface Props { 8 | title: string 9 | checkboxList: { [key: string]: number } 10 | } 11 | const CheckboxList: React.FC = ({ title, checkboxList }: Props) => { 12 | const [searchTerm, setSearchTerm] = useState('') 13 | const { filterState } = useFilterContext() 14 | 15 | const checkIsChecked = (category: string, value: string) => { 16 | if (!filterState.hasOwnProperty(category)) { 17 | return false 18 | } else if (filterState[category].includes(value)) { 19 | return true 20 | } else { 21 | return false 22 | } 23 | } 24 | return ( 25 |
26 | 27 | {Object.keys(checkboxList).length > 10 ? ( 28 | setSearchTerm(e.currentTarget.value)} 35 | > 36 | ) : ( 37 |
38 | )} 39 |
40 | {Object.keys(checkboxList).map((e: string) => { 41 | if (e.toLowerCase().includes(searchTerm.toLowerCase())) { 42 | return ( 43 | 50 | ) 51 | } else null 52 | })} 53 |
54 |
55 | ) 56 | } 57 | 58 | export default CheckboxList 59 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar/SidebarSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './SidebarSkeleton.module.css' 3 | 4 | const SidebarSkeleton = () => { 5 | return ( 6 |
7 |
8 |
9 |
shimmer
10 |
shimmer
11 |
12 |
13 |
shimmer
14 |
shimmer
15 |
16 |
17 |
18 |
shimmer
19 |
shimmer
20 |
shimmer
21 |
shimmer
22 |
shimmer
23 |
shimmer
24 |
shimmer
25 |
26 |
27 |
shimmer
28 |
shimmer
29 |
shimmer
30 |
shimmer
31 |
shimmer
32 |
shimmer
33 |
shimmer
34 |
35 |
36 |
shimmer
37 |
shimmer
38 |
shimmer
39 |
shimmer
40 |
shimmer
41 |
shimmer
42 |
shimmer
43 |
44 |
45 | ) 46 | } 47 | 48 | export default SidebarSkeleton 49 | -------------------------------------------------------------------------------- /frontend/src/components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | grid-area: header; 3 | padding: 0 1rem; 4 | display: flex; 5 | justify-content: flex-start; 6 | align-items: center; 7 | } 8 | 9 | .logo { 10 | margin-right: 1rem; 11 | } 12 | 13 | .search { 14 | margin-left: auto; 15 | } 16 | 17 | .btn_group { 18 | margin-left: auto; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | .btn_login { 24 | white-space: nowrap; 25 | background: var(--c-bg-secondary); 26 | border: 1px solid rgba(0, 0, 0, 0.1); 27 | padding: 1rem 1rem; 28 | margin: 0 1rem; 29 | &:hover { 30 | background: var(--c-primary-light); 31 | color: var(--c-text-on-p); 32 | } 33 | } 34 | .btn_user { 35 | &:focus-within, 36 | &:active { 37 | .btn_logout { 38 | pointer-events: all !important; 39 | visibility: visible !important; 40 | opacity: 1 !important; 41 | position: absolute !important; 42 | } 43 | } 44 | & > .btn_logout { 45 | pointer-events: none; 46 | visibility: hidden; 47 | opacity: 0; 48 | cursor: pointer; 49 | position: absolute; 50 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 51 | transition: all 300ms cubic-bezier(0.25, 0.8, 0.25, 1); 52 | z-index: 99; 53 | padding: 0.5rem; 54 | border-radius: 0.5rem; 55 | background: var(--c-bg-secondary-dark); 56 | color: var(--c-text-on-p); 57 | border: 1px solid var(--c-shadow); 58 | } 59 | } 60 | 61 | @media only screen and (max-width: 800px) { 62 | .search { 63 | order: 5; 64 | flex-basis: 100%; 65 | margin-top: 8px; 66 | } 67 | .header { 68 | height: max-content; 69 | align-items: center; 70 | flex-wrap: wrap; 71 | } 72 | } 73 | @media only screen and (max-width: 550px) { 74 | .header { 75 | justify-content: center; 76 | } 77 | .btn_group { 78 | margin: 0.5rem 0; 79 | padding-right: 1.2rem; 80 | } 81 | .logo { 82 | margin: 0; 83 | } 84 | .search { 85 | margin-top: 0; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/pages/_home/index.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | grid-area: body; 3 | 4 | display: grid; 5 | grid-template-columns: 220px 1fr; 6 | grid-template-areas: 'sidebar content'; 7 | 8 | @media (max-width: 900px) { 9 | display: flex; 10 | flex-direction: column; 11 | .sidebar { 12 | overflow: auto; 13 | overflow-x: hidden; 14 | will-change: transform; 15 | position: fixed; 16 | width: 75%; 17 | height: 100%; 18 | 19 | transition: transform 190ms cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0s; 20 | transform-origin: 1px; 21 | transform: translateX(-100%); 22 | z-index: 100; 23 | background: var(--c-bg-primary); 24 | padding-top: 0px; 25 | padding: 0px; 26 | left: 0; 27 | top: 0; 28 | padding-right: 1rem; 29 | 30 | &::-webkit-scrollbar { 31 | width: 8px; 32 | padding: 0; 33 | background-color: #fff; 34 | } 35 | &::-webkit-scrollbar-thumb { 36 | border-radius: 8px; 37 | background-color: #ccc; 38 | } 39 | &::-webkit-scrollbar-track { 40 | border-radius: 10px; 41 | background-color: #eee; 42 | } 43 | 44 | & > div { 45 | box-shadow: none; 46 | border: none; 47 | } 48 | } 49 | .sidebar_open { 50 | transform: translateX(0); 51 | } 52 | } 53 | } 54 | 55 | .sidebar { 56 | grid-area: sidebar; 57 | } 58 | 59 | .content { 60 | grid-area: content; 61 | } 62 | 63 | .sidebar_background { 64 | content: ''; 65 | position: fixed; 66 | opacity: 0.5; 67 | top: 0; 68 | left: 0; 69 | z-index: 99; 70 | background: black; 71 | height: 100%; 72 | width: 100%; 73 | } 74 | 75 | .btn_set_sidebar { 76 | position: fixed; 77 | top: 0; 78 | left: 0; 79 | padding: 0.5rem; 80 | background: var(--c-pale-pink); 81 | border-top-right-radius: 1rem; 82 | border-bottom-right-radius: 1rem; 83 | 84 | display: none; 85 | z-index: 3; 86 | @media (max-width: 900px) { 87 | display: inline-block; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "computer store", 3 | "name": "store", 4 | "lang": "en", 5 | "description": "Best Place to Buy Computer", 6 | "start_url": "/", 7 | "background_color": "#ffffff", 8 | "theme_color": "#51c2d5", 9 | "dir": "ltr", 10 | "display": "standalone", 11 | "orientation": "portrait", 12 | "icons": [ 13 | { 14 | "src": "icons/icon-512x512.png", 15 | "type": "image/png", 16 | "sizes": "192x192" 17 | }, 18 | { 19 | "src": "icons/icon-192x192.png", 20 | "type": "image/png", 21 | "sizes": "192x192" 22 | }, 23 | { 24 | "src": "icons/icon-180x180.png", 25 | "type": "image/png", 26 | "sizes": "180x180" 27 | }, 28 | { 29 | "src": "icons/icon-152x152.png", 30 | "type": "image/png", 31 | "sizes": "152x152" 32 | }, 33 | { 34 | "src": "icons/icon-144x144.png", 35 | "type": "image/png", 36 | "sizes": "144x144" 37 | }, 38 | { 39 | "src": "icons/icon-120x120.png", 40 | "type": "image/png", 41 | "sizes": "120x120" 42 | }, 43 | { 44 | "src": "icons/icon-114x114.png", 45 | "type": "image/png", 46 | "sizes": "114x114" 47 | }, 48 | { 49 | "src": "icons/icon-96x96.png", 50 | "type": "image/png", 51 | "sizes": "96x96" 52 | }, 53 | { 54 | "src": "icons/icon-76x76.png", 55 | "type": "image/png", 56 | "sizes": "76x76" 57 | }, 58 | { 59 | "src": "icons/icon-72x72.png", 60 | "type": "image/png", 61 | "sizes": "72x72" 62 | }, 63 | { 64 | "src": "icons/icon-60x60.png", 65 | "type": "image/png", 66 | "sizes": "60x60" 67 | }, 68 | { 69 | "src": "icons/icon-57x57.png", 70 | "type": "image/png", 71 | "sizes": "57x57" 72 | }, 73 | { 74 | "src": "icons/icon-32x32.png", 75 | "type": "image/png", 76 | "sizes": "32x32" 77 | }, 78 | { 79 | "src": "icons/icon-16x16.png", 80 | "type": "image/png", 81 | "sizes": "16x16" 82 | } 83 | ], 84 | "prefer_related_applications": "false" 85 | } 86 | -------------------------------------------------------------------------------- /server/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Get, 5 | UseGuards, 6 | Query, 7 | Patch, 8 | UsePipes, 9 | ValidationPipe, 10 | Body, 11 | } from '@nestjs/common'; 12 | import { AuthGuard } from '@nestjs/passport'; 13 | import { UserService } from './user.service'; 14 | import { GetUser } from 'src/auth/decorators/get-user.decorator'; 15 | import { User } from './interfaces/user.interface'; 16 | import { UserDto } from './dto/user.dto'; 17 | 18 | @Controller('user') 19 | @UseGuards(AuthGuard()) 20 | export class UserController { 21 | constructor(private readonly userService: UserService) {} 22 | 23 | @Get('/') 24 | async getUser(@GetUser() user: User): Promise { 25 | return await this.userService.getUser(user.id); 26 | } 27 | 28 | @Patch('/') 29 | @UsePipes(ValidationPipe) 30 | async updateUser( 31 | @Body() userDto: UserDto, 32 | @GetUser() user: User, 33 | ): Promise { 34 | return await this.userService.updateUser(user.id, userDto); 35 | } 36 | 37 | @Post('/cart/add') 38 | async addOneToShoppingCart( 39 | @GetUser() user: User, 40 | @Query('productId') productId: string, 41 | ): Promise { 42 | return await this.userService.addOneToShoppingCart(productId, user.id); 43 | } 44 | 45 | @Post('/cart/remove') 46 | async removeOneFromShoppingCart( 47 | @GetUser() user: User, 48 | @Query('productId') productId: string, 49 | ): Promise { 50 | return await this.userService.removeOneFromShoppingCart(productId, user.id); 51 | } 52 | 53 | @Post('/cart/remove-all') 54 | async removeAllFromShoppingCart(@GetUser() user: User): Promise { 55 | return await this.userService.removeAllFromShoppingCart(user.id); 56 | } 57 | 58 | @Get('/order') 59 | async getOrder(@GetUser() user: User): Promise { 60 | return await this.userService.getOrder(user.id); 61 | } 62 | 63 | @Post('/order') 64 | async addOrder( 65 | @GetUser() user: User, 66 | @Body() data: { productsId: string[] }, 67 | ): Promise { 68 | return await this.userService.addOrder(user.id, data.productsId); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/Card/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Link from 'next/link' 3 | import Image from '../Image/Image' 4 | import styles from './Card.module.css' 5 | import cx from 'classnames' 6 | interface Props { 7 | isInCart: boolean 8 | name: string 9 | price: string 10 | image: string 11 | imageIsLazy?: 'eager' | 'lazy' 12 | id: string 13 | addOneToCart: (obj: string) => Promise 14 | removeOneFromCart: (obj: string) => Promise 15 | } 16 | const Card = React.memo(function Card({ 17 | isInCart, 18 | removeOneFromCart, 19 | addOneToCart, 20 | name, 21 | price, 22 | image, 23 | imageIsLazy, 24 | id, 25 | }: Props) { 26 | const [isLoading, setIsLoading] = useState(false) 27 | 28 | const removeHandler = async (e: React.MouseEvent) => { 29 | setIsLoading(true) 30 | e.stopPropagation() 31 | await removeOneFromCart(id) 32 | setIsLoading(false) 33 | } 34 | const addHandler = async (e: React.MouseEvent) => { 35 | setIsLoading(true) 36 | e.stopPropagation() 37 | await addOneToCart(id) 38 | setIsLoading(false) 39 | } 40 | return ( 41 | 42 |
43 |
44 | 45 |

46 | {name} 47 |

48 |
49 |

${price.toLocaleString()}

50 | 51 | {isInCart ? ( 52 | 59 | ) : ( 60 | 63 | )} 64 |
65 |
66 |
67 | 68 | ) 69 | }) 70 | 71 | export default Card 72 | -------------------------------------------------------------------------------- /frontend/src/components/CheckBox/CheckboxList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import user from '@testing-library/user-event' 3 | import { render } from '@testing-library/react' 4 | 5 | import CheckboxList from './CheckboxList' 6 | import { FilterContextProvider } from 'src/context/FilterContext/FilterContext' 7 | 8 | jest.mock('next/router', () => ({ 9 | useRouter: jest.fn().mockImplementation(() => ({ 10 | push: jest.fn(() => null), 11 | })), 12 | })) 13 | 14 | const Wrapper: React.FC = ({ children }) => { 15 | return {children} 16 | } 17 | 18 | test('checkbox list renders properly, ', () => { 19 | const data = listTestData() 20 | const { getAllByRole, getByText, getByRole, queryByPlaceholderText, rerender } = render( 21 | 22 | 23 | , 24 | ) 25 | 26 | expect(getByText(data.title + 's')).toBeInTheDocument() 27 | expect(getAllByRole('checkbox').length).toEqual(Object.keys(data.list).length) 28 | expect(getByRole('searchbox')).toBeInTheDocument() 29 | 30 | rerender( 31 | 32 | 33 | , 34 | ) 35 | expect(queryByPlaceholderText(/search/i)).not.toBeInTheDocument() 36 | }) 37 | 38 | test('checkbox list search, ', () => { 39 | const data = listTestData() 40 | const { getAllByRole, queryByPlaceholderText, queryAllByRole } = render( 41 | 42 | 43 | , 44 | ) 45 | 46 | const input = queryByPlaceholderText(/search/i) as HTMLInputElement 47 | 48 | user.type(input, 'Asus') 49 | expect(getAllByRole('checkbox').length).toEqual(1) 50 | 51 | user.type(input, 'fffffffffffffff') 52 | expect(queryAllByRole('checkbox').length).toEqual(0) 53 | 54 | user.clear(input) 55 | expect(getAllByRole('checkbox').length).toEqual(Object.keys(data.list).length) 56 | }) 57 | 58 | function listTestData() { 59 | return { 60 | title: 'Test', 61 | list: { 62 | Asus: 39, 63 | Acer: 39, 64 | Razer: 14, 65 | MSI: 41, 66 | HP: 18, 67 | Apple: 10, 68 | Gigabyte: 5, 69 | Lenovo: 30, 70 | Microsoft: 24, 71 | Dell: 9, 72 | Aorus: 3, 73 | Samsung: 6, 74 | }, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/components/Slider/Slider.module.css: -------------------------------------------------------------------------------- 1 | .slider { 2 | display: flex; 3 | flex-direction: column; 4 | text-align: center; 5 | justify-content: center; 6 | margin: 0 auto; 7 | width: 100%; 8 | } 9 | .slider_container { 10 | position: relative; 11 | width: 100%; 12 | height: 16px; 13 | } 14 | 15 | .title { 16 | margin-right: auto; 17 | font-size: 0.875rem; 18 | font-weight: 700; 19 | color: var(--c-text); 20 | letter-spacing: 0.25px; 21 | } 22 | 23 | .slider_fill { 24 | padding: 0; 25 | margin: 0; 26 | position: absolute; 27 | top: 6px; 28 | height: 3px; 29 | background-color: var(--c-primary); 30 | z-index: 2; 31 | user-select: none; 32 | display: block; 33 | } 34 | .text { 35 | margin: 0.5rem 0; 36 | position: relative; 37 | width: 100%; 38 | height: 20px; 39 | font-size: 14px; 40 | color: var(--c-primary); 41 | 42 | display: flex; 43 | justify-content: space-between; 44 | align-items: center; 45 | } 46 | .text_range1 { 47 | display: block; 48 | position: relative; 49 | float: left; 50 | text-align: center; 51 | } 52 | .text_range2 { 53 | position: relative; 54 | float: right; 55 | text-align: center; 56 | } 57 | .slider_bg { 58 | display: block; 59 | position: absolute; 60 | top: 6px; 61 | width: 100%; 62 | height: 3px; 63 | 64 | z-index: 1; 65 | user-select: none; 66 | } 67 | 68 | .input[type='range'] { 69 | transform-origin: center; 70 | padding: 0; 71 | margin: 0; 72 | -webkit-appearance: none; 73 | position: absolute; 74 | left: 0; 75 | outline: none; 76 | width: 100%; 77 | height: 100%; 78 | } 79 | 80 | .input[type='range']::-webkit-slider-runnable-track { 81 | border-radius: 4px; 82 | width: 100%; 83 | height: 100%; 84 | cursor: pointer; 85 | } 86 | 87 | .input[type='range']::-webkit-slider-thumb { 88 | -webkit-appearance: none; 89 | background: #1ea7fd; 90 | border: 1px solid #0000003f; 91 | width: 16px; 92 | height: 16px; 93 | border-radius: 16px; 94 | 95 | pointer-events: all; 96 | position: relative; 97 | z-index: 4; 98 | cursor: pointer; 99 | transition: box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; 100 | } 101 | .input[type='range']:focus::-webkit-slider-thumb { 102 | box-shadow: 0px 0px 0px 8px rgba(25, 118, 210, 0.16); 103 | } 104 | -------------------------------------------------------------------------------- /server/src/product/dto/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsIn, IsNumber, IsOptional } from 'class-validator'; 2 | import productFieldValues from '../product.field-values.js'; 3 | import { Types } from 'mongoose'; 4 | import { ApiProperty } from '@nestjs/swagger'; 5 | 6 | export class CreateProductDto { 7 | @ApiProperty() 8 | @IsOptional() 9 | SellerName: string; 10 | 11 | @IsOptional() 12 | @ApiProperty({ 13 | type: String, 14 | }) 15 | Seller: Types.ObjectId; 16 | 17 | @ApiProperty() 18 | Part: string; 19 | 20 | @ApiProperty() 21 | @IsNotEmpty() 22 | Model: string; 23 | 24 | @ApiProperty() 25 | 'Screen Size': string; 26 | 27 | @ApiProperty() 28 | @IsIn(productFieldValues.screenPanelType) 29 | 'Screen Panel Type': string; 30 | 31 | @ApiProperty() 32 | @IsIn(productFieldValues.resolution) 33 | Resolution: string; 34 | 35 | @ApiProperty() 36 | @IsIn(productFieldValues.refreshRate) 37 | 'Refresh Rate': string; 38 | 39 | @ApiProperty() 40 | @IsNotEmpty() 41 | Dimensions: string; 42 | 43 | @ApiProperty() 44 | @IsNumber() 45 | Weight: number; 46 | 47 | @ApiProperty() 48 | @IsIn(productFieldValues.cpuCoreCount) 49 | 'CPU Core Count': number; 50 | 51 | @ApiProperty() 52 | @IsNotEmpty() 53 | 'CPU Core Clock': string; 54 | 55 | @ApiProperty() 56 | @IsNotEmpty() 57 | 'CPU Boost Clock': string; 58 | 59 | @ApiProperty() 60 | @IsIn(productFieldValues.memory) 61 | Memory: number; 62 | 63 | @ApiProperty() 64 | @IsNotEmpty() 65 | CPU: string; 66 | 67 | @ApiProperty() 68 | @IsNotEmpty() 69 | 'CPU Microarchitecture': string; 70 | 71 | @ApiProperty() 72 | @IsNotEmpty() 73 | 'SSD Storage': string; 74 | 75 | @ApiProperty() 76 | @IsNotEmpty() 77 | 'SSD Type': string; 78 | 79 | @ApiProperty() 80 | @IsNotEmpty() 81 | Storage: string; 82 | 83 | @ApiProperty() 84 | @IsNotEmpty() 85 | GPU: string; 86 | 87 | @ApiProperty() 88 | @IsNotEmpty() 89 | 'GPU Memory': number; 90 | 91 | @ApiProperty() 92 | @IsNotEmpty() 93 | 'Operating System': string; 94 | 95 | @ApiProperty() 96 | @IsIn(['Yes', 'No']) 97 | 'SD Card Reader': string; 98 | 99 | @ApiProperty() 100 | @IsNotEmpty() 101 | 'Front Facing Webcam': string; 102 | 103 | @ApiProperty() 104 | @IsNotEmpty() 105 | Images: []; 106 | 107 | @ApiProperty() 108 | @IsNotEmpty() 109 | Name: string; 110 | 111 | @ApiProperty() 112 | @IsNumber() 113 | Price: number; 114 | 115 | @ApiProperty() 116 | @IsIn(productFieldValues.manufacturer) 117 | Manufacturer: string; 118 | } 119 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "computer-store", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "Alican Erdurmaz", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "start:dist": "node dist/main.js", 10 | "prebuild": "rimraf dist", 11 | "build": "nest build", 12 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 13 | "start": "nest start", 14 | "start:dev": "nest start --watch", 15 | "start:debug": "nest start --debug --watch", 16 | "start:prod": "node dist/main", 17 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 18 | "test": "jest", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "7.0.0", 26 | "@nestjs/config": "0.5.0", 27 | "@nestjs/core": "7.0.0", 28 | "@nestjs/jwt": "7.1.0", 29 | "@nestjs/mongoose": "7.0.2", 30 | "@nestjs/passport": "7.1.0", 31 | "@nestjs/platform-express": "7.0.0", 32 | "@nestjs/swagger": "4.7.8", 33 | "bcrypt": "5.0.0", 34 | "class-transformer": "0.3.1", 35 | "class-validator": "0.12.2", 36 | "mongoose": "5.9.25", 37 | "passport": "0.4.1", 38 | "passport-jwt": "4.0.0", 39 | "reflect-metadata": "0.1.13", 40 | "rimraf": "3.0.2", 41 | "rxjs": "6.5.4", 42 | "swagger-ui-express": "4.1.5" 43 | }, 44 | "devDependencies": { 45 | "@nestjs/cli": "7.0.0", 46 | "@nestjs/schematics": "7.0.0", 47 | "@nestjs/testing": "7.0.0", 48 | "@types/bcrypt": "3.0.0", 49 | "@types/express": "4.17.3", 50 | "@types/jest": "25.2.3", 51 | "@types/mongoose": "5.7.32", 52 | "@types/node": "13.9.1", 53 | "@types/supertest": "2.0.8", 54 | "@typescript-eslint/eslint-plugin": "3.0.2", 55 | "@typescript-eslint/parser": "3.0.2", 56 | "eslint": "7.1.0", 57 | "eslint-config-prettier": "6.10.0", 58 | "eslint-plugin-import": "2.20.1", 59 | "jest": "26.0.1", 60 | "prettier": "1.19.1", 61 | "supertest": "4.0.2", 62 | "ts-jest": "26.1.0", 63 | "ts-loader": "6.2.1", 64 | "ts-node": "8.6.2", 65 | "tsconfig-paths": "3.9.0", 66 | "typescript": "3.7.4" 67 | }, 68 | "jest": { 69 | "moduleFileExtensions": [ 70 | "js", 71 | "json", 72 | "ts" 73 | ], 74 | "rootDir": "src", 75 | "testRegex": ".spec.ts$", 76 | "transform": { 77 | "^.+\\.(t|j)s$": "ts-jest" 78 | }, 79 | "coverageDirectory": "../coverage", 80 | "testEnvironment": "node" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/components/ProductListHeader/ProductListHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import styles from './ProductListHeader.module.css' 4 | import Chip from '../Chip/Chip' 5 | import { AnimatePresence, motion } from 'framer-motion' 6 | import { IAction, useFilterContext } from 'src/context/FilterContext/FilterContext' 7 | import TimesIcon from '../Icons/TimesIcon' 8 | 9 | const sliders = ['Price', 'Weight'] 10 | 11 | const ProductListHeader = () => { 12 | const { filterState, filterDispatch } = useFilterContext() 13 | 14 | const onClickHandler = (category: string, value: string, dispatchType: string) => { 15 | filterDispatch({ 16 | type: dispatchType as IAction['type'], 17 | payload: { 18 | category, 19 | value, 20 | }, 21 | }) 22 | } 23 | const deleteFilters = () => { 24 | filterDispatch({ 25 | type: 'delete-all', 26 | payload: { 27 | category: '', 28 | value: '', 29 | }, 30 | }) 31 | } 32 | return ( 33 | 34 | {filterState && Object.keys(filterState).length < 1 ? null : ( 35 | 40 | {filterState && 41 | Object.keys(filterState).map((e: any) => { 42 | if (sliders.includes(e)) { 43 | return ( 44 | } 46 | aria-label={`select ${filterState[e]}`} 47 | key={filterState[e]} 48 | category={e} 49 | value={filterState[e]} 50 | onClick={() => onClickHandler(e, filterState[e], 'delete-string')} 51 | > 52 | ) 53 | } else { 54 | return filterState[e]?.map((v: any) => { 55 | return ( 56 | } 58 | key={v} 59 | category={e} 60 | value={v} 61 | onClick={() => onClickHandler(e, v, 'delete')} 62 | > 63 | ) 64 | }) 65 | } 66 | })} 67 | 68 | 71 | 72 | )} 73 | 74 | ) 75 | } 76 | 77 | export default ProductListHeader 78 | -------------------------------------------------------------------------------- /frontend/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | 3 | import styles from './Header.module.css' 4 | 5 | import Logo from '../Logo/Logo' 6 | import SearchBar from '../SearchBar/SearchBar' 7 | import IconButton from '../Button/IconButton' 8 | import UserIcon from '../Icons/UserIcon' 9 | import CartIcon from '../Icons/CartIcon' 10 | import ButtonBadge from '../Button/ButtonBadge' 11 | import Link from 'next/link' 12 | import { useUserContext } from 'src/context/UserContext/UserContext' 13 | import Button from '../Button/Button' 14 | import { useRouter } from 'next/router' 15 | 16 | const Header = () => { 17 | const [badgeCount, setBadgeCount] = useState(0) 18 | const router = useRouter() 19 | const { 20 | userState, 21 | dispatchUserState, 22 | dispatchCartInLocalStorage, 23 | setAccessToken, 24 | cartInLocalStorage, 25 | } = useUserContext() 26 | 27 | const logout = () => { 28 | dispatchUserState({ 29 | type: 'delete', 30 | payload: null as any, 31 | }) 32 | dispatchCartInLocalStorage({ 33 | type: 'delete-from-cart-all', 34 | payload: null as any, 35 | }) 36 | setAccessToken(null) 37 | router.push('/') 38 | } 39 | 40 | useEffect(() => { 41 | calculateCartLength() 42 | }, [cartInLocalStorage, userState]) 43 | 44 | const calculateCartLength = () => { 45 | if (userState) { 46 | setBadgeCount(userState.shoppingCart.length) 47 | } else { 48 | setBadgeCount(cartInLocalStorage.split(',').length - 1) 49 | } 50 | } 51 | return ( 52 |
53 | 54 | 55 | 56 | 57 |
58 | {userState ? ( 59 |
60 | }> 61 |
62 | Do you wanna Log Out ? 63 |
64 |
65 | ) : ( 66 | 67 | 68 | 71 | 72 | 73 | )} 74 | 75 | 76 | } 79 | badge={} 80 | > 81 | 82 | 83 |
84 |
85 | ) 86 | } 87 | 88 | export default Header 89 | -------------------------------------------------------------------------------- /server/src/product/product.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import productFieldValues from './product.field-values.js'; 3 | 4 | export const productSchema = new mongoose.Schema({ 5 | Model: { 6 | type: String, 7 | required: true, 8 | text: true, 9 | }, 10 | Part: { 11 | type: String, 12 | }, 13 | Seller: { type: mongoose.Schema.Types.ObjectId, ref: 'user' }, 14 | 15 | 'Screen Size': { 16 | type: String, 17 | required: true, 18 | }, 19 | 'Screen Panel Type': { 20 | type: String, 21 | enum: productFieldValues.screenPanelType, 22 | required: true, 23 | }, 24 | Resolution: { 25 | type: String, 26 | enum: productFieldValues.resolution, 27 | required: true, 28 | }, 29 | 'Refresh Rate': { 30 | type: String, 31 | enum: productFieldValues.refreshRate, 32 | required: true, 33 | }, 34 | Dimensions: { 35 | type: String, 36 | lowercase: true, 37 | trim: true, 38 | required: true, 39 | }, 40 | Weight: { 41 | type: Number, 42 | required: true, 43 | }, 44 | 'CPU Core Count': { 45 | type: Number, 46 | enum: productFieldValues.cpuCoreCount, 47 | required: true, 48 | }, 49 | 'CPU Core Clock': { 50 | type: String, 51 | required: true, 52 | }, 53 | 'CPU Boost Clock': { 54 | type: String, 55 | required: true, 56 | }, 57 | Memory: { 58 | type: Number, 59 | enum: productFieldValues.memory, 60 | required: true, 61 | }, 62 | CPU: { 63 | type: String, 64 | required: true, 65 | }, 66 | 'CPU Microarchitecture': { 67 | type: String, 68 | }, 69 | 'SSD Storage': { 70 | type: String, 71 | required: true, 72 | }, 73 | 'SSD Type': { 74 | type: String, 75 | required: true, 76 | }, 77 | Storage: { 78 | type: String, 79 | required: true, 80 | }, 81 | GPU: { 82 | type: String, 83 | required: true, 84 | }, 85 | 'GPU Memory': { 86 | type: Number, 87 | enum: productFieldValues.memory, 88 | required: true, 89 | }, 90 | 'Operating System': { 91 | type: String, 92 | required: true, 93 | }, 94 | 'SD Card Reader': { 95 | type: String, 96 | lowercase: true, 97 | trim: true, 98 | enum: ['yes', 'no'], 99 | }, 100 | 'Front Facing Webcam': { 101 | type: String, 102 | required: true, 103 | }, 104 | Images: { 105 | type: Array, 106 | }, 107 | Name: { 108 | type: String, 109 | required: true, 110 | text: true, 111 | }, 112 | Price: { 113 | type: Number, 114 | required: true, 115 | }, 116 | Manufacturer: { 117 | type: String, 118 | trim: true, 119 | enum: productFieldValues.manufacturer, 120 | required: true, 121 | }, 122 | }); 123 | 124 | productSchema.index({ Name: 'text', Model: 'text' }); 125 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build-export": "next build && next export", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "storybook": "start-storybook -p 6006", 11 | "build-storybook": "build-storybook", 12 | "test": "jest", 13 | "prettier": "prettier --write '**/*.{ts,js,tsx,jsx,css,html}'", 14 | "cy:run": "cypress run", 15 | "cy:open": "cypress open", 16 | "test:e2e:dev": "start-server-and-test dev http://localhost:3000 cy:open" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "yarn test" 21 | } 22 | }, 23 | "dependencies": { 24 | "classnames": "2.2.6", 25 | "framer-motion": "2.4.3-beta.2", 26 | "next": "9.5.5", 27 | "react": "16.13.1", 28 | "react-dom": "16.13.1", 29 | "react-hook-form": "6.5.1", 30 | "react-query": "2.5.14", 31 | "react-query-devtools": "2.4.2", 32 | "start-server-and-test": "1.11.3" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "7.11.1", 36 | "@babel/helper-builder-react-jsx": "7.12.13", 37 | "@babel/helper-builder-react-jsx-experimental": "7.12.11", 38 | "@babel/helper-define-map": "7.13.12", 39 | "@babel/plugin-transform-react-jsx": "7.13.12", 40 | "@storybook/addon-actions": "6.0.17", 41 | "@storybook/addon-essentials": "6.0.17", 42 | "@storybook/addon-knobs": "6.0.17", 43 | "@storybook/addon-links": "6.0.17", 44 | "@storybook/react": "6.0.17", 45 | "@testing-library/cypress": "6.0.0", 46 | "@testing-library/dom": "7.22.1", 47 | "@testing-library/jest-dom": "5.11.3", 48 | "@testing-library/react": "10.4.8", 49 | "@testing-library/react-hooks": "3.4.1", 50 | "@testing-library/user-event": "12.1.1", 51 | "@types/classnames": "2.2.10", 52 | "@types/jest": "26.0.9", 53 | "@types/node": "14.0.27", 54 | "@types/react": "16.9.46", 55 | "@types/react-dom": "16.9.9", 56 | "@types/react-query": "1.1.2", 57 | "@types/testing-library__react": "10.2.0", 58 | "autoprefixer": "9.8.6", 59 | "babel-jest": "26.3.0", 60 | "babel-loader": "8.0.5", 61 | "css-loader": "4.2.1", 62 | "cypress": "5.0.0", 63 | "fork-ts-checker-webpack-plugin": "5.1.0", 64 | "husky": "4.2.5", 65 | "identity-obj-proxy": "3.0.0", 66 | "jest": "26.4.0", 67 | "jest-watch-typeahead": "0.6.0", 68 | "msw": "0.20.5", 69 | "next-pwa": "5.0.4", 70 | "node-fetch": "2.6.1", 71 | "postcss-custom-properties": "9.1.1", 72 | "postcss-flexbugs-fixes": "4.2.1", 73 | "postcss-import": "12.0.1", 74 | "postcss-loader": "3.0.0", 75 | "postcss-nested": "4.2.3", 76 | "postcss-preset-env": "6.7.0", 77 | "react-is": "16.13.1", 78 | "react-test-renderer": "16.13.1", 79 | "style-loader": "1.2.1", 80 | "ts-loader": "8.0.2", 81 | "typescript": "4.2.4" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/components/Form/AuthForm.module.css: -------------------------------------------------------------------------------- 1 | .btn_group { 2 | background: var(--c-bg-primary); 3 | border-radius: 16px; 4 | border: 2px solid var(--c-border); 5 | width: 100%; 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | margin-bottom: 2rem; 10 | & > button { 11 | outline: none; 12 | flex: 1; 13 | font-weight: 600; 14 | border-radius: 16px; 15 | } 16 | } 17 | 18 | .form { 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | flex-direction: column; 23 | width: 100%; 24 | margin: 2rem 0; 25 | margin-bottom: 0; 26 | } 27 | 28 | .input_container { 29 | width: 100%; 30 | display: flex; 31 | justify-content: flex-start; 32 | align-items: center; 33 | flex-direction: column; 34 | 35 | position: relative; 36 | z-index: 1; 37 | margin-bottom: 2rem; 38 | 39 | & > label { 40 | background: var(--c-bg-secondary); 41 | border-radius: 8px; 42 | padding: 0 0.725rem; 43 | display: block; 44 | position: absolute; 45 | top: -0.3rem; 46 | left: 0; 47 | z-index: 99; 48 | font-size: 0.75rem; 49 | letter-spacing: 0.5px; 50 | } 51 | & > input { 52 | border: solid 2px var(--c-border); 53 | border-radius: 8px; 54 | padding: 0.725rem; 55 | width: 100%; 56 | background: var(--c-bg-secondary); 57 | } 58 | } 59 | .active { 60 | box-shadow: 0 1px 2px var(--shadow); 61 | background: var(--c-bg-secondary) !important; 62 | } 63 | 64 | .btn_submit { 65 | margin-top: 2rem; 66 | width: 100%; 67 | position: relative; 68 | & > button { 69 | width: 100%; 70 | background: var(--c-primary-light); 71 | color: var(--c-text-on-p); 72 | position: relative; 73 | } 74 | } 75 | 76 | .text_error { 77 | color: red; 78 | width: 100%; 79 | text-align: start; 80 | padding-top: 0.5rem; 81 | } 82 | .spinner { 83 | margin: 0 auto !important; 84 | } 85 | .show_password { 86 | width: 32px; 87 | height: 26px; 88 | cursor: pointer; 89 | background: white; 90 | position: absolute; 91 | top: -12px; 92 | right: -30px; 93 | 94 | & > span { 95 | opacity: 0.6; 96 | display: inline-block; 97 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTUgMTJjMCAxLjY1NC0xLjM0NiAzLTMgM3MtMy0xLjM0Ni0zLTMgMS4zNDYtMyAzLTMgMyAxLjM0NiAzIDN6bTktLjQ0OXMtNC4yNTIgOC40NDktMTEuOTg1IDguNDQ5Yy03LjE4IDAtMTIuMDE1LTguNDQ5LTEyLjAxNS04LjQ0OXM0LjQ0Ni03LjU1MSAxMi4wMTUtNy41NTFjNy42OTQgMCAxMS45ODUgNy41NTEgMTEuOTg1IDcuNTUxem0tNyAuNDQ5YzAtMi43NTctMi4yNDMtNS01LTVzLTUgMi4yNDMtNSA1IDIuMjQzIDUgNSA1IDUtMi4yNDMgNS01eiIvPjwvc3ZnPg=='); 98 | background-repeat: no-repeat; 99 | background-position: center; 100 | width: 32px; 101 | height: 32px; 102 | &:hover { 103 | opacity: 1; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /frontend/pages/product/product-page.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | grid-area: body; 3 | max-width: 1200px; 4 | height: 1000px; 5 | 6 | display: grid; 7 | grid-template-rows: max-content 1fr; 8 | grid-template-areas: 'summary' 'specs'; 9 | } 10 | 11 | .summary_container { 12 | box-shadow: 0 1px 2px var(--shadow); 13 | border-radius: 8px; 14 | 15 | grid-area: summary; 16 | display: grid; 17 | grid-template-columns: 1fr 1fr; 18 | grid-template-areas: 'image info'; 19 | 20 | .image_container { 21 | grid-area: image; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | & > img { 26 | width: 100%; 27 | } 28 | } 29 | 30 | .info_container { 31 | grid-area: info; 32 | background: var(--c-bg-primary); 33 | padding: 2rem 1rem; 34 | padding-right: 0; 35 | border-radius: 0; 36 | border-left: 2px solid var(--c-border); 37 | .name { 38 | font-weight: 600; 39 | font-size: 1.25rem; 40 | } 41 | .link { 42 | margin-bottom: 0.25rem; 43 | color: #537ecf; 44 | font-weight: 600; 45 | } 46 | .seller { 47 | border: 2px solid var(--c-border); 48 | padding: 0.5rem; 49 | margin: 2rem 0; 50 | margin-right: 1rem; 51 | 52 | background: var(--c-bg-secondary); 53 | 54 | & > span { 55 | font-weight: 600; 56 | color: var(--c-text-secondary); 57 | } 58 | } 59 | .price_button { 60 | display: flex; 61 | justify-content: flex-start; 62 | align-items: center; 63 | .price { 64 | font-size: 2rem; 65 | font-weight: 600; 66 | margin-left: 0.25rem; 67 | } 68 | .button { 69 | margin-left: 1rem; 70 | cursor: pointer; 71 | font-weight: 700; 72 | font-size: 13px; 73 | padding: 0.5rem; 74 | border-radius: 4px; 75 | 76 | color: var(--c-text-on-p); 77 | background: var(--c-primary); 78 | 79 | transition: border var(--a-60-cubic); 80 | border: 2px solid var(--c-bg-secondary); 81 | &:hover { 82 | color: var(--c-text-on-p); 83 | background: var(--c-primary-light); 84 | } 85 | } 86 | } 87 | } 88 | } 89 | .specs_container { 90 | grid-area: specs; 91 | border-top: 2px solid var(--c-border); 92 | background: var(--c-bg-secondary); 93 | padding: 1rem; 94 | table { 95 | border-collapse: collapse; 96 | } 97 | tr { 98 | border-bottom: 1px solid var(--c-border); 99 | border-radius: 8px; 100 | &:hover { 101 | background: var(--c-bg-primary-dark); 102 | } 103 | } 104 | td { 105 | vertical-align: baseline; 106 | text-align: start; 107 | padding: 1rem 0; 108 | } 109 | .category { 110 | display: inline-block; 111 | width: max-content; 112 | } 113 | .value { 114 | padding-left: 2rem; 115 | } 116 | } 117 | 118 | 119 | .color_pink { 120 | color: #f56a79 !important; 121 | &:hover { 122 | color: var(--c-text-on-p); 123 | background: #f56a79; 124 | } 125 | } -------------------------------------------------------------------------------- /frontend/pages/auth/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | grid-area: body; 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | 8 | margin: 0 auto; 9 | margin-top: 1rem; 10 | 11 | background: var(--c-bg-secondary); 12 | border-radius: 16px; 13 | box-shadow: 0 1px 2px var(--shadow); 14 | padding: 1.5rem 2rem; 15 | } 16 | 17 | .btn_group { 18 | background: var(--c-bg-primary); 19 | border-radius: 16px; 20 | border: 2px solid var(--c-border); 21 | width: 100%; 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | margin-bottom: 2rem; 26 | & > button { 27 | outline: none; 28 | flex: 1; 29 | font-weight: 600; 30 | border-radius: 16px; 31 | } 32 | } 33 | 34 | .form { 35 | display: flex; 36 | justify-content: center; 37 | align-items: center; 38 | flex-direction: column; 39 | width: 100%; 40 | margin: 2rem 0; 41 | margin-bottom: 0; 42 | } 43 | 44 | .input_container { 45 | width: 100%; 46 | display: flex; 47 | justify-content: flex-start; 48 | align-items: center; 49 | flex-direction: column; 50 | 51 | position: relative; 52 | z-index: 1; 53 | margin-bottom: 2rem; 54 | 55 | & > label { 56 | background: var(--c-bg-secondary); 57 | border-radius: 8px; 58 | padding: 0 0.725rem; 59 | display: block; 60 | position: absolute; 61 | top: -0.3rem; 62 | left: 0; 63 | z-index: 99; 64 | font-size: 0.75rem; 65 | letter-spacing: 0.5px; 66 | } 67 | & > input { 68 | border: solid 2px var(--c-border); 69 | border-radius: 8px; 70 | padding: 0.725rem; 71 | width: 100%; 72 | background: var(--c-bg-secondary); 73 | } 74 | } 75 | .active { 76 | box-shadow: 0 1px 2px var(--shadow); 77 | background: var(--c-bg-secondary) !important; 78 | } 79 | 80 | .btn_submit { 81 | margin-top: 2rem; 82 | width: 100%; 83 | & > button { 84 | width: 100%; 85 | background: var(--c-primary-light); 86 | color: var(--c-text-on-p); 87 | } 88 | } 89 | 90 | .text_error { 91 | color: red; 92 | width: 100%; 93 | text-align: start; 94 | padding-top: 0.5rem; 95 | } 96 | 97 | .show_password { 98 | width: 32px; 99 | height: 26px; 100 | cursor: pointer; 101 | background: white; 102 | position: absolute; 103 | top: -12px; 104 | right: -30px; 105 | 106 | & > span { 107 | opacity: 0.6; 108 | display: inline-block; 109 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTUgMTJjMCAxLjY1NC0xLjM0NiAzLTMgM3MtMy0xLjM0Ni0zLTMgMS4zNDYtMyAzLTMgMyAxLjM0NiAzIDN6bTktLjQ0OXMtNC4yNTIgOC40NDktMTEuOTg1IDguNDQ5Yy03LjE4IDAtMTIuMDE1LTguNDQ5LTEyLjAxNS04LjQ0OXM0LjQ0Ni03LjU1MSAxMi4wMTUtNy41NTFjNy42OTQgMCAxMS45ODUgNy41NTEgMTEuOTg1IDcuNTUxem0tNyAuNDQ5YzAtMi43NTctMi4yNDMtNS01LTVzLTUgMi4yNDMtNSA1IDIuMjQzIDUgNSA1IDUtMi4yNDMgNS01eiIvPjwvc3ZnPg=='); 110 | background-repeat: no-repeat; 111 | background-position: center; 112 | width: 32px; 113 | height: 32px; 114 | &:hover { 115 | opacity: 1; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /server/src/product/product.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | import { 3 | Controller, 4 | Get, 5 | Post, 6 | Query, 7 | Body, 8 | UsePipes, 9 | ValidationPipe, 10 | Param, 11 | Delete, 12 | Patch, 13 | UseGuards, 14 | } from '@nestjs/common'; 15 | import { ProductService } from './product.service'; 16 | import { Product } from './interfaces/product.interface'; 17 | import { CreateProductDto } from './dto/create-product.dto'; 18 | import { ProductFiltersPipe } from './pipes/product.filters-pipe'; 19 | import { AuthGuard } from '@nestjs/passport'; 20 | import { GetUser } from 'src/auth/decorators/get-user.decorator'; 21 | import { User } from 'src/user/interfaces/user.interface'; 22 | import { ApiResponse } from '@nestjs/swagger'; 23 | 24 | @Controller('product') 25 | export class ProductController { 26 | constructor(private readonly productService: ProductService) {} 27 | 28 | @Get('/') 29 | async getProducts( 30 | @Query(ProductFiltersPipe) 31 | filter: Record, 32 | ): Promise<{ products: Product[]; numberOfPages: number }> { 33 | return await this.productService.getProducts(filter); 34 | } 35 | 36 | @ApiResponse({ 37 | description: 'Returns all the computer specs that can be filterable', 38 | }) 39 | @Get('/filters') 40 | getFilters(): any { 41 | return this.productService.getFilters(); 42 | } 43 | 44 | @Get('/get-all-ids') 45 | async getAllIds(): Promise { 46 | return this.productService.getAllIds(); 47 | } 48 | @Get('/search') 49 | async searchProducts(@Query('term') term: string): Promise { 50 | return this.productService.searchProducts(term); 51 | } 52 | 53 | @Get('/user') 54 | @UseGuards(AuthGuard()) 55 | async getUserProducts(@GetUser() user: User): Promise { 56 | return await this.productService.getUserProducts(user.id); 57 | } 58 | 59 | @Get('/find-many') 60 | async getManyProduct(@Query('idArray') idArray: string): Promise { 61 | return this.productService.getManyProduct(idArray); 62 | } 63 | 64 | @Get('/:id') 65 | async getProductById(@Param('id') id: string): Promise { 66 | return this.productService.getProductById(id); 67 | } 68 | 69 | @Post() 70 | @UseGuards(AuthGuard()) 71 | @UsePipes(ValidationPipe) 72 | async addProduct( 73 | @GetUser() user: User, 74 | @Body() createProductDto: CreateProductDto, 75 | ): Promise { 76 | createProductDto.Seller = user.id; 77 | createProductDto.SellerName = user.name; 78 | return await this.productService.createProduct(createProductDto); 79 | } 80 | 81 | @Patch('/:id') 82 | @UseGuards(AuthGuard()) 83 | @UsePipes(ValidationPipe) 84 | async updateProduct( 85 | @Body() createProductDto: CreateProductDto, 86 | @GetUser() user: User, 87 | @Param('id') id: string, 88 | ): Promise { 89 | return await this.productService.updateProduct(createProductDto, id, user); 90 | } 91 | 92 | @UseGuards(AuthGuard()) 93 | @Delete('/:id') 94 | async removeProduct( 95 | @GetUser() user: User, 96 | @Param('id') id: string, 97 | ): Promise<{ id: string }> { 98 | return this.productService.deleteProduct(id, user); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /frontend/src/context/FilterContext/FilterContext.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import React, { createContext, useReducer, useContext, useEffect, useState } from 'react' 3 | import { createQuery } from 'src/utils/changeQuery' 4 | 5 | interface IFilterContext { 6 | filterDispatch: React.Dispatch 7 | filterState: any 8 | pagination: number | null 9 | setPagination: React.Dispatch> 10 | } 11 | const FilterContext = createContext(undefined) 12 | 13 | export interface IAction { 14 | type: 'add' | 'delete' | 'add-string' | 'delete-string' | 'delete-all' | 'toggle' 15 | payload: { 16 | category: string 17 | value: string 18 | } 19 | } 20 | 21 | const filterReducer = (state: any, action: IAction) => { 22 | switch (action.type) { 23 | case 'add': { 24 | const oldState = state 25 | 26 | if (!oldState.hasOwnProperty(action.payload.category)) { 27 | oldState[action.payload.category] = [] 28 | } 29 | oldState[action.payload.category].push(action.payload.value) 30 | 31 | return { ...oldState } 32 | } 33 | case 'delete': { 34 | if (!state || !state.hasOwnProperty(action.payload.category)) return { ...state } 35 | 36 | const newArray = state[action.payload.category]?.filter((e: string) => e !== action.payload.value) 37 | 38 | if (newArray.length === 0) { 39 | delete state[action.payload.category] 40 | return { ...state } 41 | } 42 | 43 | state[action.payload.category] = newArray 44 | return { ...state } 45 | } 46 | 47 | case 'add-string': { 48 | const oldState = state 49 | 50 | oldState[action.payload.category] = [action.payload.value] 51 | 52 | return { ...oldState } 53 | } 54 | 55 | case 'delete-string': { 56 | if (!state || !state.hasOwnProperty(action.payload.category)) return { ...state } 57 | const oldState = state 58 | 59 | delete oldState[action.payload.category] 60 | 61 | return { ...oldState } 62 | } 63 | 64 | case 'toggle': { 65 | const oldState = state 66 | 67 | oldState[action.payload.category] = [action.payload.value] 68 | 69 | return { ...oldState } 70 | } 71 | 72 | case 'delete-all': { 73 | return {} 74 | } 75 | 76 | default: { 77 | throw new Error(`Unhandled action type: ${action.type}`) 78 | } 79 | } 80 | } 81 | 82 | export const FilterContextProvider: React.FC = ({ children }) => { 83 | const [filterState, filterDispatch] = useReducer(filterReducer, {}) 84 | const [pagination, setPagination] = useState(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 | Nest Logo 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 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 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 |
65 | {activePage === 'Sign up' ? ( 66 |
67 | 70 | 71 |
72 | ) : null} 73 |
74 | 77 | 78 |
79 | 80 |
81 | 84 | 90 | setShowPassword(!showPassword)}> 91 | 92 | 93 |
94 |
95 |

{serverError}

96 |
97 | {submitting ? ( 98 | 99 | ) : ( 100 |
101 | 104 |
105 | )} 106 | 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 |
81 | 101 |
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 | 109 | 110 | 111 | ) 112 | } 113 | })} 114 | 115 |
{e}{product[e]}
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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 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 | --------------------------------------------------------------------------------