online shop app

This commit is contained in:
2023-01-06 16:30:17 +01:00
parent ea5924bf48
commit 018ee29abd
21 changed files with 30351 additions and 1 deletions
+32
View File
@@ -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
View File
@@ -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;
}
+26
View File
@@ -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;
+49
View File
@@ -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;
+103
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
const Footer = () => {
const getCurrentYear = () => {
return new Date().getFullYear();
};
return <div className='footer'>Dan Dobos &copy; {getCurrentYear()}</div>;
};
export default Footer;
+30
View File
@@ -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;
+29
View File
@@ -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;
+32
View File
@@ -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;
+13
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
export const Capitalize = (text) => {
return text.replace(/[_-]/g, " ").replace(/(^\w|\s\w)(\S*)/g, (_,m1,m2) => m1.toUpperCase()+m2.toLowerCase())
};
+116
View File
@@ -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;
+13
View File
@@ -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;
+30
View File
@@ -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>
);