online shop app
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
node_modules
|
||||
@@ -1 +1,36 @@
|
||||
# online-shop
|
||||
# Online Shop
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
## Description
|
||||
---
|
||||
The Online Shop is build with React. It has custom responsive layout styled with SCSS. It makes an API call to fetch mock products from [Dummy JSON API](https://dummyjson.com/products) with Redux [RTK Query](https://redux-toolkit.js.org/rtk-query/overview). Added also Toastify for notifications.
|
||||
The application shows a product home page and a cart page, the routing is done with React Router.
|
||||
|
||||
|
||||
|
||||
## Installation
|
||||
---
|
||||
### Under the repository name, click on clone or download. In the terminal paste this command:
|
||||
|
||||
```git clone https://dadobos.github.io/online-shop.git```
|
||||
|
||||
### Open the project in VSCode or move inside the folder with:
|
||||
|
||||
```cd online-shop```
|
||||
|
||||
### Install the npm packages:
|
||||
|
||||
```npm install```
|
||||
|
||||
### Run the application with:
|
||||
|
||||
```npm start```
|
||||
|
||||
### In the browser open the "3000" port:
|
||||
|
||||
```http://localhost:3000/```
|
||||
Generated
+29220
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "online-shop",
|
||||
"homepage": "https://dadobos.github.io/online-shop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-toastify": "^9.1.1",
|
||||
"sass": "^1.56.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"predeploy": "npm run build",
|
||||
"deploy": "gh-pages -d build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="https://res.cloudinary.com/dadobos/image/upload/v1642766520/web/favicon_ugvc88.ico"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#335c67" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Online Shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"short_name": "React Online Shop",
|
||||
"name": "React Online Shop",
|
||||
"icons": [
|
||||
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import './App.scss';
|
||||
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import Home from './components/Home';
|
||||
import Cart from './components/Cart';
|
||||
import NotFound from './components/NotFound';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className='App'>
|
||||
<BrowserRouter>
|
||||
<ToastContainer />
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path='cart' exact element={<Cart />} />
|
||||
<Route path='/' exact element={<Home />} />
|
||||
<Route path='*' element={<NotFound />} />
|
||||
</Routes>
|
||||
<Footer/>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Nanum+Gothic+Coding:wght@400;700&display=swap");
|
||||
|
||||
// Variables
|
||||
|
||||
// colors
|
||||
$OuterSpaceCrayola: #22363c;
|
||||
$DeepSpaceSparkle: #335c67;
|
||||
$Cultured: #eceff1;
|
||||
$PersianGreen: #009688;
|
||||
$SolidPink: #92374d;
|
||||
|
||||
//size
|
||||
$NavbarHeight: 64px;
|
||||
$FooterHeight: 22px;
|
||||
|
||||
$xs: 21rem; // 336px;
|
||||
$sm: 48rem; // 768px;
|
||||
$md: 64rem; // 1024px;
|
||||
$lg: 90rem; // 1440px;
|
||||
$xl: 120rem; // 1920px;
|
||||
$breakpoints: $xs, $sm, $md, $lg, $xl;
|
||||
|
||||
// Mixins
|
||||
@mixin layout($breakpoint, $direction: min) {
|
||||
@if $direction == min {
|
||||
@media (min-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
} @else {
|
||||
@media (max-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
// use the custom value if the breakpoint is not part of the pre-defined list
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin hover-animation {
|
||||
&:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
// reset
|
||||
* {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Nanum Gothic Coding", monospace;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $PersianGreen;
|
||||
@include hover-animation();
|
||||
}
|
||||
|
||||
// layout
|
||||
.responsive-layout {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
@include layout($xl, min) {
|
||||
max-width: $lg;
|
||||
}
|
||||
@include layout($xl, max) {
|
||||
max-width: $lg;
|
||||
}
|
||||
|
||||
@include layout($lg, max) {
|
||||
max-width: $md;
|
||||
}
|
||||
|
||||
@include layout($md, max) {
|
||||
max-width: $sm;
|
||||
}
|
||||
@include layout($sm, max) {
|
||||
max-width: $xs;
|
||||
}
|
||||
}
|
||||
|
||||
// spinner
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
padding: 8px;
|
||||
// aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: $PersianGreen;
|
||||
--_m: conic-gradient(#0000 10%, #000), linear-gradient(#000 0 0) content-box;
|
||||
-webkit-mask: var(--_m);
|
||||
mask: var(--_m);
|
||||
-webkit-mask-composite: source-out;
|
||||
mask-composite: subtract;
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
// navbar
|
||||
.navbar-background {
|
||||
background-color: $OuterSpaceCrayola;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: $NavbarHeight;
|
||||
align-items: center;
|
||||
@extend .responsive-layout;
|
||||
|
||||
.title {
|
||||
@include hover-animation();
|
||||
font-size: 2rem;
|
||||
float: left;
|
||||
color: $Cultured;
|
||||
@include layout($md, max) {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
@include layout($sm, max) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-bag {
|
||||
float: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $Cultured;
|
||||
svg {
|
||||
color: $Cultured;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.nav-quantity {
|
||||
@include flex-center();
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
background-color: $PersianGreen;
|
||||
|
||||
margin-left: 5px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footer
|
||||
.footer {
|
||||
@include flex-center();
|
||||
height: $FooterHeight;
|
||||
color: $Cultured;
|
||||
background-color: $OuterSpaceCrayola;
|
||||
}
|
||||
|
||||
// home
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
min-height: calc(100vh - $NavbarHeight - $FooterHeight - 2rem);
|
||||
h2 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// card
|
||||
.card-list {
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid $DeepSpaceSparkle;
|
||||
border-radius: 4px;
|
||||
width: 250px;
|
||||
max-width: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
@include hover-animation();
|
||||
|
||||
button {
|
||||
border: 1px solid $PersianGreen;
|
||||
border-radius: 20px;
|
||||
outline: none;
|
||||
// background-color: $Cultured;
|
||||
// background-color: $PersianGreen;
|
||||
// color: $Cultured;
|
||||
color: $PersianGreen;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
letter-spacing: 1.1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 10rem;
|
||||
min-height: 8rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.image-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.2rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.category,
|
||||
.price,
|
||||
.discount,
|
||||
.stock {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.category {
|
||||
span {
|
||||
background-color: $OuterSpaceCrayola;
|
||||
color: $Cultured;
|
||||
font-size: 10px;
|
||||
border-radius: 20px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
// border-bottom: 1px solid #335c67;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cart
|
||||
.cart-container {
|
||||
min-height: calc(100vh - $NavbarHeight - $FooterHeight - 2rem);
|
||||
|
||||
.titles {
|
||||
margin: 2rem 0 1rem 0;
|
||||
border-bottom: 1px solid $OuterSpaceCrayola;
|
||||
.product-title {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.total-title {
|
||||
padding-right: 0.5rem;
|
||||
justify-self: right;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
margin-top: 2rem;
|
||||
border-bottom: 0.5px solid $OuterSpaceCrayola;
|
||||
|
||||
.cart-product {
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
max-width: 100%;
|
||||
max-height: 100px;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
h3 {
|
||||
font-weight: 400;
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin: 1rem;
|
||||
background: none;
|
||||
color: $SolidPink;
|
||||
&:hover {
|
||||
color: $PersianGreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quantity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 130px;
|
||||
max-width: 100%;
|
||||
border: 0.5px solid $OuterSpaceCrayola;
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
padding: 0.7rem 1.5rem;
|
||||
cursor: pointer;
|
||||
// font-size: 2rem;
|
||||
}
|
||||
.count {
|
||||
padding: 0.7rem 0;
|
||||
}
|
||||
}
|
||||
.total-price {
|
||||
justify-self: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-item,
|
||||
.titles {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 3fr 1fr 1fr 1fr;
|
||||
column-gap: 0.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding-top: 2rem;
|
||||
|
||||
.clear-cart {
|
||||
@include hover-animation();
|
||||
width: 130px;
|
||||
max-width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 1.1px;
|
||||
border: 0.5px solid $SolidPink;
|
||||
color: $SolidPink;
|
||||
background: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.cart-checkout {
|
||||
width: 270px;
|
||||
max-width: 100%;
|
||||
.subtotal {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
.amount {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@include hover-animation();
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
background-color: $OuterSpaceCrayola;
|
||||
color: $Cultured;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
height: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
letter-spacing: 1.1px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.continue-shopping {
|
||||
@include flex-center();
|
||||
}
|
||||
.no-cart-items {
|
||||
@include flex-center();
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - $NavbarHeight - $FooterHeight - 4rem);
|
||||
p {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include layout($sm, max) {
|
||||
.titles {
|
||||
display: none;
|
||||
}
|
||||
.cart-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 20rem;
|
||||
border: 0.5px solid $OuterSpaceCrayola;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
.cart-product {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
img {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
& > div > p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button{
|
||||
font-size: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
.price{
|
||||
margin-bottom: 1rem;
|
||||
&::before{
|
||||
content: "Price:";
|
||||
}
|
||||
}
|
||||
.total-price{
|
||||
margin-top: 1rem;
|
||||
&::before{
|
||||
content: "Total Price:";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
flex-direction: column;
|
||||
.clear-cart {
|
||||
margin-bottom: 2rem;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// not found
|
||||
.not-found {
|
||||
@include flex-center();
|
||||
flex-direction: column;
|
||||
color: $OuterSpaceCrayola;
|
||||
min-height: calc(100vh - $NavbarHeight - $FooterHeight);
|
||||
h2 {
|
||||
height: 48px;
|
||||
}
|
||||
p {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
// back to home button
|
||||
.back-to-home {
|
||||
font-size: 1.4rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.go-to-cart {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const BackHomeButton = (props) => {
|
||||
const { text } = props;
|
||||
return (
|
||||
<Link to='/' className='back-to-home'>
|
||||
<span>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='16'
|
||||
height='16'
|
||||
fill='currentColor'
|
||||
className='bi bi-arrow-left'
|
||||
viewBox='0 0 16 16'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span> {text}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackHomeButton;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addToCart, getTotals } from '../features/cartSlice';
|
||||
|
||||
import { Capitalize } from '../features/Utils';
|
||||
|
||||
const Card = ({ product }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const cart = useSelector((state) => state.cart);
|
||||
useEffect(() => {
|
||||
dispatch(getTotals());
|
||||
}, [cart, dispatch]);
|
||||
|
||||
const handleAddToCart = (product) => {
|
||||
dispatch(addToCart(product));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className='title'>{Capitalize(product.title)}</h3>
|
||||
<div className='image-container'>
|
||||
<img src={product.images[0]} alt={Capitalize(product.title)} />
|
||||
</div>
|
||||
<div className='category'>
|
||||
<span>{Capitalize(product.category)}</span>
|
||||
<span>{Capitalize(product.brand)}</span>
|
||||
</div>
|
||||
<p className='description'>{Capitalize(product.description)}</p>
|
||||
<div>
|
||||
<p className='discount'>
|
||||
<span>Discount: </span>
|
||||
<span>{product.discountPercentage}%</span>
|
||||
</p>
|
||||
<p className='price'>
|
||||
<span>Price:</span>
|
||||
<span>€{product.price}</span>
|
||||
</p>
|
||||
<p className='stock'>
|
||||
<span>Stoc:</span>
|
||||
<span>{product.stock}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => handleAddToCart(product)}>Add to cart</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
addToCart,
|
||||
decreaseCart,
|
||||
removeFromCart,
|
||||
clearCart,
|
||||
getTotals,
|
||||
} from '../features/cartSlice';
|
||||
|
||||
import { Capitalize } from '../features/Utils';
|
||||
import BackHomeButton from './BackHomeButton';
|
||||
|
||||
const Cart = () => {
|
||||
const cart = useSelector((state) => state.cart);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getTotals());
|
||||
}, [cart, dispatch]);
|
||||
|
||||
const handleRemoveFromCart = (cartItem) => {
|
||||
dispatch(removeFromCart(cartItem));
|
||||
};
|
||||
const handleDecreaseCart = (cartItem) => {
|
||||
dispatch(decreaseCart(cartItem));
|
||||
};
|
||||
const handleIncreaseCart = (cartItem) => {
|
||||
dispatch(addToCart(cartItem));
|
||||
};
|
||||
const handleClearCart = () => {
|
||||
dispatch(clearCart());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='responsive-layout'>
|
||||
<div className='cart-container'>
|
||||
<h2>Cart</h2>
|
||||
{cart.cartItems.length === 0 ? (
|
||||
<div className='no-cart-items'>
|
||||
<p>Your cart is empty</p>
|
||||
<BackHomeButton text={'Start Shopping'} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className='titles'>
|
||||
<h3 className='product-title'>Product</h3>
|
||||
<h3>Price</h3>
|
||||
<h3>Quantity</h3>
|
||||
<h3 className='total-title'>Total</h3>
|
||||
</div>
|
||||
<>
|
||||
{cart.cartItems?.map((cartItem) => (
|
||||
<div className='cart-item' key={cartItem.id}>
|
||||
<div className='cart-product'>
|
||||
<img src={cartItem.images[0]} alt={cartItem.title} />
|
||||
<div>
|
||||
<h3>{Capitalize(cartItem.title)}</h3>
|
||||
<p>{cartItem.description}</p>
|
||||
<button onClick={() => handleRemoveFromCart(cartItem)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='price'>€{cartItem.price}</div>
|
||||
<div className='quantity'>
|
||||
<button onClick={() => handleDecreaseCart(cartItem)}>
|
||||
-
|
||||
</button>
|
||||
<div className='count'>{cartItem.cartQuantity}</div>
|
||||
<button onClick={() => handleIncreaseCart(cartItem)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className='total-price'>
|
||||
€{cartItem.price * cartItem.cartQuantity}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<div className='cart-summary'>
|
||||
<button className='clear-cart' onClick={() => handleClearCart()}>
|
||||
Clear Cart
|
||||
</button>
|
||||
<div className='cart-checkout'>
|
||||
<div className='subtotal'>
|
||||
<span>Subtotal</span>
|
||||
<span className='amount'>€{cart.cartTotalAmount}</span>
|
||||
</div>
|
||||
<button>Checkout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='continue-shopping'>
|
||||
<BackHomeButton text={'Continue Shopping'} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cart;
|
||||
@@ -0,0 +1,9 @@
|
||||
const Footer = () => {
|
||||
const getCurrentYear = () => {
|
||||
return new Date().getFullYear();
|
||||
};
|
||||
|
||||
return <div className='footer'>Dan Dobos © {getCurrentYear()}</div>;
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const GoToCartButton = (props) => {
|
||||
const { text } = props;
|
||||
return (
|
||||
<div>
|
||||
{text}
|
||||
<br />
|
||||
<Link to='/cart' className='go-to-cart'>
|
||||
<span>Go To Cart </span>
|
||||
<span>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='14'
|
||||
height='8'
|
||||
fill='currentColor'
|
||||
className='bi bi-arrow-right'
|
||||
viewBox='0 0 16 16'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoToCartButton;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useGetProductsQuery } from '../features/productsApi';
|
||||
import Card from './Card';
|
||||
|
||||
const Home = () => {
|
||||
const { data, error, isLoading } = useGetProductsQuery();
|
||||
|
||||
return (
|
||||
<div className='responsive-layout'>
|
||||
<section className='home-container'>
|
||||
<h2>Products</h2>
|
||||
{isLoading ? (
|
||||
<p className='spinner'></p>
|
||||
) : error ? (
|
||||
<p>An error occoured: {error} </p>
|
||||
) : (
|
||||
<>
|
||||
{data.products?.map((product) => (
|
||||
<article className='card-list' key={product.id}>
|
||||
<Card product={product} />
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const Navbar = () => {
|
||||
const { cartTotalQuantity } = useSelector((state) => state.cart);
|
||||
|
||||
return (
|
||||
<div className='navbar-background'>
|
||||
<nav className='navbar'>
|
||||
<Link to='/' className='title'>
|
||||
React Online Shop
|
||||
</Link>
|
||||
<Link to='/cart'>
|
||||
<div className='nav-bag'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='currentColor'
|
||||
className='bi bi-bag'
|
||||
viewBox='0 0 16 16'>
|
||||
<path d='M8 1a2.5 2.5 0 0 1 2.5 2.5V4h-5v-.5A2.5 2.5 0 0 1 8 1zm3.5 3v-.5a3.5 3.5 0 1 0-7 0V4H1v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4h-3.5zM2 5h12v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5z' />
|
||||
</svg>
|
||||
<span className='nav-quantity'>
|
||||
<span>{cartTotalQuantity}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,13 @@
|
||||
import BackHomeButton from './BackHomeButton'
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className='not-found'>
|
||||
<h2>404</h2>
|
||||
<p>Page not found</p>
|
||||
<BackHomeButton text={"Home"}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Capitalize = (text) => {
|
||||
return text.replace(/[_-]/g, " ").replace(/(^\w|\s\w)(\S*)/g, (_,m1,m2) => m1.toUpperCase()+m2.toLowerCase())
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Capitalize } from './Utils';
|
||||
import GoToCartButton from '../components/GoToCartButton';
|
||||
|
||||
const initialState = {
|
||||
cartItems: localStorage.getItem('cartItems')
|
||||
? JSON.parse(localStorage.getItem('cartItems'))
|
||||
: [],
|
||||
cartTotalQuantity: 0,
|
||||
cartTotalAmount: 0,
|
||||
};
|
||||
|
||||
const toastifyOptions = {
|
||||
position: 'bottom-left',
|
||||
icon: false,
|
||||
};
|
||||
|
||||
const cartSlice = createSlice({
|
||||
name: 'cart',
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart(state, action) {
|
||||
const itemIndex = state.cartItems.findIndex(
|
||||
(item) => item.id === action.payload.id
|
||||
);
|
||||
if (itemIndex >= 0) {
|
||||
state.cartItems[itemIndex].cartQuantity += 1;
|
||||
|
||||
let multipleItemsText = `Increased ${Capitalize(
|
||||
state.cartItems[itemIndex].title
|
||||
)} quantity to ${state.cartItems[itemIndex].cartQuantity}`;
|
||||
|
||||
|
||||
|
||||
toast.info(
|
||||
window.location.href.indexOf('cart') !== -1 ? (
|
||||
multipleItemsText
|
||||
) : (
|
||||
<GoToCartButton text={multipleItemsText} />
|
||||
),
|
||||
toastifyOptions
|
||||
);
|
||||
} else {
|
||||
const tempProduct = { ...action.payload, cartQuantity: 1 };
|
||||
state.cartItems.push(tempProduct);
|
||||
let firstItemText = `${Capitalize(action.payload.title)} added to cart`
|
||||
|
||||
toast.success(
|
||||
<GoToCartButton text={firstItemText} />,
|
||||
toastifyOptions
|
||||
);
|
||||
}
|
||||
localStorage.setItem('cartItems', JSON.stringify(state.cartItems));
|
||||
},
|
||||
removeFromCart(state, action) {
|
||||
const nextCartItems = state.cartItems.filter(
|
||||
(cartItem) => cartItem.id !== action.payload.id
|
||||
);
|
||||
state.cartItems = nextCartItems;
|
||||
localStorage.setItem('cartItems', JSON.stringify(state.cartItems));
|
||||
toast.error(`${Capitalize(action.payload.title)} removed from cart`, toastifyOptions);
|
||||
},
|
||||
decreaseCart(state, action) {
|
||||
const itemIndex = state.cartItems.findIndex(
|
||||
(cartItem) => cartItem.id === action.payload.id
|
||||
);
|
||||
if (state.cartItems[itemIndex].cartQuantity > 1) {
|
||||
state.cartItems[itemIndex].cartQuantity -= 1;
|
||||
toast.info(
|
||||
`Decreased ${Capitalize(action.payload.title)} cart quantity`,
|
||||
toastifyOptions
|
||||
);
|
||||
} else if (state.cartItems[itemIndex].cartQuantity === 1) {
|
||||
const nextCartItems = state.cartItems.filter(
|
||||
(cartItem) => cartItem.id !== action.payload.id
|
||||
);
|
||||
state.cartItems = nextCartItems;
|
||||
toast.error(
|
||||
`${Capitalize(action.payload.title)} removed from cart`,
|
||||
toastifyOptions
|
||||
);
|
||||
}
|
||||
localStorage.setItem('cartItems', JSON.stringify(state.cartItems));
|
||||
},
|
||||
clearCart(state, action) {
|
||||
state.cartItems = [];
|
||||
toast.error(`Cart cleared`, toastifyOptions);
|
||||
localStorage.setItem('cartItems', JSON.stringify(state.cartItems));
|
||||
},
|
||||
getTotals(state, action) {
|
||||
let { total, quantity } = state.cartItems.reduce(
|
||||
(cartTotal, cartItem) => {
|
||||
const { price, cartQuantity } = cartItem;
|
||||
const itemTotal = price * cartQuantity;
|
||||
|
||||
cartTotal.total += itemTotal;
|
||||
cartTotal.quantity += cartQuantity;
|
||||
|
||||
return cartTotal;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
quantity: 0,
|
||||
}
|
||||
);
|
||||
state.cartTotalQuantity = quantity;
|
||||
state.cartTotalAmount = total;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addToCart, removeFromCart, decreaseCart, clearCart, getTotals } =
|
||||
cartSlice.actions;
|
||||
|
||||
export default cartSlice.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
export const productsApi = createApi({
|
||||
reducerPath: 'productsApi',
|
||||
baseQuery: fetchBaseQuery({ baseUrl: 'https://dummyjson.com/' }),
|
||||
endpoints: (builder) => ({
|
||||
getProducts: builder.query({
|
||||
query: () => 'products',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetProductsQuery } = productsApi;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { setupListeners } from '@reduxjs/toolkit/query';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { productsApi } from './features/productsApi';
|
||||
import cartReducer from './features/cartSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
cart: cartReducer,
|
||||
[productsApi.reducerPath]: productsApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(productsApi.middleware),
|
||||
});
|
||||
|
||||
setupListeners(store.dispatch);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user