+ {availableColors.map((color) => (
+
setColor(color)}
+ style={{ backgroundColor: color }}
+ role="presentation"
+ />
+ ))}
+
+ );
+};
+
+ColorChooser.propTypes = {
+ availableColors: PropType.arrayOf(PropType.string).isRequired,
+ onSelectedColorChange: PropType.func.isRequired
+};
+
+export default ColorChooser;
diff --git a/src/components/common/Filters.jsx b/src/components/common/Filters.jsx
new file mode 100644
index 0000000..cd6fa16
--- /dev/null
+++ b/src/components/common/Filters.jsx
@@ -0,0 +1,166 @@
+/* eslint-disable no-nested-ternary */
+import { useDidMount } from 'hooks';
+import PropType from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory, withRouter } from 'react-router-dom';
+import { applyFilter, resetFilter } from 'redux/actions/filterActions';
+import { selectMax, selectMin } from 'selectors/selector';
+import PriceRange from './PriceRange';
+
+const Filters = ({ closeModal }) => {
+ const { filter, isLoading, products } = useSelector((state) => ({
+ filter: state.filter,
+ isLoading: state.app.loading,
+ products: state.products.items
+ }));
+ const [field, setFilter] = useState({
+ brand: filter.brand,
+ minPrice: filter.minPrice,
+ maxPrice: filter.maxPrice,
+ sortBy: filter.sortBy
+ });
+ const dispatch = useDispatch();
+ const history = useHistory();
+ const didMount = useDidMount();
+
+ const max = selectMax(products);
+ const min = selectMin(products);
+
+ useEffect(() => {
+ if (didMount && window.screen.width <= 480) {
+ history.push('/');
+ }
+
+ if (didMount && closeModal) closeModal();
+
+ setFilter(filter);
+ window.scrollTo(0, 0);
+ }, [filter]);
+
+
+ const onPriceChange = (minVal, maxVal) => {
+ setFilter({ ...field, minPrice: minVal, maxPrice: maxVal });
+ };
+
+ const onBrandFilterChange = (e) => {
+ const val = e.target.value;
+
+ setFilter({ ...field, brand: val });
+ };
+
+ const onSortFilterChange = (e) => {
+ setFilter({ ...field, sortBy: e.target.value });
+ };
+
+ const onApplyFilter = () => {
+ const isChanged = Object.keys(field).some((key) => field[key] !== filter[key]);
+
+ if (field.minPrice > field.maxPrice) {
+ return;
+ }
+
+ if (isChanged) {
+ dispatch(applyFilter(field));
+ } else {
+ closeModal();
+ }
+ };
+
+ const onResetFilter = () => {
+ const filterFields = ['brand', 'minPrice', 'maxPrice', 'sortBy'];
+
+ if (filterFields.some((key) => !!filter[key])) {
+ dispatch(resetFilter());
+ } else {
+ closeModal();
+ }
+ };
+
+ return (
+
+
+ Brand
+
+
+ {products.length === 0 && isLoading ? (
+
Loading Filter
+ ) : (
+
+ All Brands
+ Salt Maalat
+ Betsin Maalat
+ canvas
+ nike
+
+ )}
+
+
+ Sort By
+
+
+
+ None
+ Name Ascending A - Z
+ Name Descending Z - A
+ Price High - Low
+ Price Low - High
+
+
+
+
Price Range
+
+
+ {(products.length === 0 && isLoading) || max === 0 ? (
+
Loading Filter
+ ) : products.length === 1 ? (
+
No Price Range
+ ) : (
+
+ )}
+
+
+
+ Apply filters
+
+
+ Reset filters
+
+
+
+ );
+};
+
+Filters.propTypes = {
+ closeModal: PropType.func.isRequired
+};
+
+export default withRouter(Filters);
diff --git a/src/components/common/FiltersToggle.jsx b/src/components/common/FiltersToggle.jsx
new file mode 100644
index 0000000..736a21b
--- /dev/null
+++ b/src/components/common/FiltersToggle.jsx
@@ -0,0 +1,45 @@
+import { useModal } from 'hooks';
+import PropType from 'prop-types';
+import React from 'react';
+import Filters from './Filters';
+import Modal from './Modal';
+
+const FiltersToggle = ({ children }) => {
+ const { isOpenModal, onOpenModal, onCloseModal } = useModal();
+
+ return (
+ <>
+
+ {children}
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+FiltersToggle.propTypes = {
+ children: PropType.oneOfType([
+ PropType.arrayOf(PropType.node),
+ PropType.node
+ ]).isRequired
+};
+
+export default FiltersToggle;
diff --git a/src/components/common/Footer.jsx b/src/components/common/Footer.jsx
new file mode 100644
index 0000000..59f7ab7
--- /dev/null
+++ b/src/components/common/Footer.jsx
@@ -0,0 +1,44 @@
+import * as Route from 'constants/routes';
+import logo from 'images/logo-full.png';
+import React from 'react';
+import { useLocation } from 'react-router-dom';
+
+const Footer = () => {
+ const { pathname } = useLocation();
+
+ const visibleOnlyPath = [
+ Route.HOME,
+ Route.SHOP
+ ];
+
+ return !visibleOnlyPath.includes(pathname) ? null : (
+
+
+
+
+
+ ©
+ {new Date().getFullYear()}
+
+
+
+
+
+ Fork this project
+ HERE
+
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/common/ImageLoader.jsx b/src/components/common/ImageLoader.jsx
new file mode 100644
index 0000000..f004da1
--- /dev/null
+++ b/src/components/common/ImageLoader.jsx
@@ -0,0 +1,42 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import PropType from 'prop-types';
+import React, { useState } from 'react';
+
+const ImageLoader = ({ src, alt, className }) => {
+ const loadedImages = {};
+ const [loaded, setLoaded] = useState(loadedImages[src]);
+
+ const onLoad = () => {
+ loadedImages[src] = true;
+ setLoaded(true);
+ };
+
+ return (
+ <>
+ {!loaded && (
+
+ )}
+
+ >
+ );
+};
+
+ImageLoader.defaultProps = {
+ className: 'image-loader'
+};
+
+ImageLoader.propTypes = {
+ src: PropType.string.isRequired,
+ alt: PropType.string.isRequired,
+ className: PropType.string
+};
+
+export default ImageLoader;
diff --git a/src/components/common/MessageDisplay.jsx b/src/components/common/MessageDisplay.jsx
new file mode 100644
index 0000000..00dba75
--- /dev/null
+++ b/src/components/common/MessageDisplay.jsx
@@ -0,0 +1,36 @@
+import PropType from 'prop-types';
+import React from 'react';
+
+const MessageDisplay = ({
+ message, description, buttonLabel, action
+}) => (
+
+
{message || 'Message'}
+ {description && {description} }
+
+ {action && (
+
+ {buttonLabel || 'Okay'}
+
+ )}
+
+);
+
+MessageDisplay.defaultProps = {
+ description: undefined,
+ buttonLabel: 'Okay',
+ action: undefined
+};
+
+MessageDisplay.propTypes = {
+ message: PropType.string.isRequired,
+ description: PropType.string,
+ buttonLabel: PropType.string,
+ action: PropType.func
+};
+
+export default MessageDisplay;
diff --git a/src/components/common/MobileNavigation.jsx b/src/components/common/MobileNavigation.jsx
new file mode 100644
index 0000000..2557854
--- /dev/null
+++ b/src/components/common/MobileNavigation.jsx
@@ -0,0 +1,89 @@
+import { BasketToggle } from 'components/basket';
+import { HOME, SIGNIN } from 'constants/routes';
+import PropType from 'prop-types';
+import React from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import UserNav from 'views/account/components/UserAvatar';
+import Badge from './Badge';
+import FiltersToggle from './FiltersToggle';
+import SearchBar from './SearchBar';
+
+const Navigation = (props) => {
+ const {
+ isAuthenticating, basketLength, disabledPaths, user
+ } = props;
+ const { pathname } = useLocation();
+
+ const onClickLink = (e) => {
+ if (isAuthenticating) e.preventDefault();
+ };
+
+ return (
+
+
+
+
+
SALINAKA
+
+
+
+
+ {({ onClickToggle }) => (
+
+
+
+
+
+
+ )}
+
+
+ {user ? (
+
+
+
+ ) : (
+ <>
+ {pathname !== SIGNIN && (
+
+
+ Sign In
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+Navigation.propTypes = {
+ isAuthenticating: PropType.bool.isRequired,
+ basketLength: PropType.number.isRequired,
+ disabledPaths: PropType.arrayOf(PropType.string).isRequired,
+ user: PropType.oneOfType([
+ PropType.bool,
+ PropType.object
+ ]).isRequired
+};
+
+export default Navigation;
diff --git a/src/components/common/Modal.jsx b/src/components/common/Modal.jsx
new file mode 100644
index 0000000..49f26da
--- /dev/null
+++ b/src/components/common/Modal.jsx
@@ -0,0 +1,64 @@
+import PropType from 'prop-types';
+import React from 'react';
+import AppModal from 'react-modal';
+
+const Modal = ({
+ isOpen,
+ onRequestClose,
+ afterOpenModal,
+ overrideStyle,
+ children
+}) => {
+ const defaultStyle = {
+ content: {
+ top: '50%',
+ left: '50%',
+ right: 'auto',
+ bottom: 'auto',
+ position: 'fixed',
+ padding: '50px 20px',
+ transition: 'all .5s ease',
+ zIndex: 9999,
+ marginRight: '-50%',
+ transform: 'translate(-50%, -50%)',
+ boxShadow: '0 5px 10px rgba(0, 0, 0, .1)',
+ animation: 'scale .3s ease',
+ ...overrideStyle
+ }
+ };
+
+ AppModal.setAppElement('#app');
+
+ return (
+
+ {children}
+
+ );
+};
+
+Modal.defaultProps = {
+ overrideStyle: {},
+ afterOpenModal: () => { }
+};
+
+Modal.propTypes = {
+ isOpen: PropType.bool.isRequired,
+ onRequestClose: PropType.func.isRequired,
+ afterOpenModal: PropType.func,
+ // eslint-disable-next-line react/forbid-prop-types
+ overrideStyle: PropType.object,
+ children: PropType.oneOfType([
+ PropType.arrayOf(PropType.node),
+ PropType.node
+ ]).isRequired
+};
+
+export default Modal;
diff --git a/src/components/common/Navigation.jsx b/src/components/common/Navigation.jsx
new file mode 100644
index 0000000..92f81b1
--- /dev/null
+++ b/src/components/common/Navigation.jsx
@@ -0,0 +1,138 @@
+/* eslint-disable indent */
+import { FilterOutlined, ShoppingOutlined } from '@ant-design/icons';
+import * as ROUTE from 'constants/routes';
+import logo from 'images/logo-full.png';
+import React, { useEffect, useRef } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ Link, NavLink, useLocation
+} from 'react-router-dom';
+import UserAvatar from 'views/account/components/UserAvatar';
+import BasketToggle from '../basket/BasketToggle';
+import Badge from './Badge';
+import FiltersToggle from './FiltersToggle';
+import MobileNavigation from './MobileNavigation';
+import SearchBar from './SearchBar';
+
+const Navigation = () => {
+ const navbar = useRef(null);
+ const { pathname } = useLocation();
+
+ const store = useSelector((state) => ({
+ basketLength: state.basket.length,
+ user: state.auth,
+ isAuthenticating: state.app.isAuthenticating,
+ isLoading: state.app.loading
+ }));
+
+ const scrollHandler = () => {
+ if (navbar.current && window.screen.width > 480) {
+ if (window.pageYOffset >= 70) {
+ navbar.current.classList.add('is-nav-scrolled');
+ } else {
+ navbar.current.classList.remove('is-nav-scrolled');
+ }
+ }
+ };
+
+ useEffect(() => {
+ window.addEventListener('scroll', scrollHandler);
+ return () => window.removeEventListener('scroll', scrollHandler);
+ }, []);
+
+ const onClickLink = (e) => {
+ if (store.isAuthenticating) e.preventDefault();
+ };
+
+ // disable the basket toggle to these pathnames
+ const basketDisabledpathnames = [
+ ROUTE.CHECKOUT_STEP_1,
+ ROUTE.CHECKOUT_STEP_2,
+ ROUTE.CHECKOUT_STEP_3,
+ ROUTE.SIGNIN,
+ ROUTE.SIGNUP,
+ ROUTE.FORGOT_PASSWORD
+ ];
+
+ if (store.user && store.user.role === 'ADMIN') {
+ return null;
+ } if (window.screen.width <= 800) {
+ return (
+
+ );
+ }
+ return (
+
+
+
+
+
+ Home
+ Shop
+ Featured
+ Recommended
+
+ {(pathname === ROUTE.SHOP || pathname === ROUTE.SEARCH) && (
+
+
+ Filters
+
+
+
+ )}
+
+
+
+
+ {({ onClickToggle }) => (
+
+
+
+
+
+
+ )}
+
+
+ {store.user ? (
+
+
+
+ ) : (
+
+ {pathname !== ROUTE.SIGNUP && (
+
+ Sign Up
+
+ )}
+ {pathname !== ROUTE.SIGNIN && (
+
+ Sign In
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default Navigation;
diff --git a/src/components/common/Preloader.jsx b/src/components/common/Preloader.jsx
new file mode 100644
index 0000000..f288c0d
--- /dev/null
+++ b/src/components/common/Preloader.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import logoWordmark from '../../../static/logo-wordmark.png';
+
+const Preloader = () => (
+
+
+
+
+
+
+
+);
+
+export default Preloader;
diff --git a/src/components/common/PriceRange/Handle.jsx b/src/components/common/PriceRange/Handle.jsx
new file mode 100644
index 0000000..d9248da
--- /dev/null
+++ b/src/components/common/PriceRange/Handle.jsx
@@ -0,0 +1,112 @@
+import PropType from 'prop-types';
+import React, { Component } from 'react';
+
+class Handle extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ mouseOver: false
+ };
+ }
+
+
+ onMouseEnter() {
+ this.setState({ mouseOver: true });
+ }
+
+ onMouseLeave() {
+ this.setState({ mouseOver: false });
+ }
+
+ render() {
+ const {
+ domain: [min, max],
+ handle: { id, value, percent },
+ isActive,
+ disabled,
+ getHandleProps
+ } = this.props;
+ const { mouseOver } = this.state;
+
+ return (
+ <>
+ {(mouseOver || isActive) && !disabled ? (
+
+
+
+ Value:
+ {value}
+
+
+
+ ) : null}
+
+
+ >
+ );
+ }
+}
+
+Handle.propTypes = {
+ // eslint-disable-next-line react/forbid-prop-types
+ domain: PropType.array.isRequired,
+ handle: PropType.shape({
+ id: PropType.string.isRequired,
+ value: PropType.number.isRequired,
+ percent: PropType.number.isRequired
+ }).isRequired,
+ getHandleProps: PropType.func.isRequired,
+ isActive: PropType.bool.isRequired,
+ disabled: PropType.bool
+};
+
+Handle.defaultProps = {
+ disabled: false
+};
+
+export default Handle;
diff --git a/src/components/common/PriceRange/SliderRail.jsx b/src/components/common/PriceRange/SliderRail.jsx
new file mode 100644
index 0000000..6be0e78
--- /dev/null
+++ b/src/components/common/PriceRange/SliderRail.jsx
@@ -0,0 +1,36 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import PropType from 'prop-types';
+import React from 'react';
+
+const railOuterStyle = {
+ position: 'absolute',
+ transform: 'translate(0%, -50%)',
+ width: '100%',
+ height: 42,
+ borderRadius: 7,
+ cursor: 'pointer'
+ // border: '1px solid grey',
+};
+
+const railInnerStyle = {
+ position: 'absolute',
+ width: '100%',
+ height: 14,
+ transform: 'translate(0%, -50%)',
+ borderRadius: 7,
+ pointerEvents: 'none',
+ backgroundColor: '#d0d0d0'
+};
+
+const SliderRail = ({ getRailProps }) => (
+
+);
+
+SliderRail.propTypes = {
+ getRailProps: PropType.func.isRequired
+};
+
+export default SliderRail;
diff --git a/src/components/common/PriceRange/Tick.jsx b/src/components/common/PriceRange/Tick.jsx
new file mode 100644
index 0000000..78836fc
--- /dev/null
+++ b/src/components/common/PriceRange/Tick.jsx
@@ -0,0 +1,46 @@
+import PropType from 'prop-types';
+import React from 'react';
+
+const Tick = ({ tick, count, format }) => (
+
+
+
+ {format(tick.value)}
+
+
+);
+
+Tick.propTypes = {
+ tick: PropType.shape({
+ id: PropType.string.isRequired,
+ value: PropType.number.isRequired,
+ percent: PropType.number.isRequired
+ }).isRequired,
+ count: PropType.number.isRequired,
+ format: PropType.func
+};
+
+Tick.defaultProps = {
+ format: (d) => d
+};
+
+export default Tick;
diff --git a/src/components/common/PriceRange/TooltipRail.jsx b/src/components/common/PriceRange/TooltipRail.jsx
new file mode 100644
index 0000000..8820553
--- /dev/null
+++ b/src/components/common/PriceRange/TooltipRail.jsx
@@ -0,0 +1,104 @@
+import PropType from 'prop-types';
+import React, { Component } from 'react';
+
+const railStyle = {
+ position: 'absolute',
+ width: '100%',
+ transform: 'translate(0%, -50%)',
+ height: 20,
+ cursor: 'pointer',
+ zIndex: 300
+ // border: '1px solid grey',
+};
+
+const railCenterStyle = {
+ position: 'absolute',
+ width: '100%',
+ transform: 'translate(0%, -50%)',
+ height: 14,
+ borderRadius: 7,
+ cursor: 'pointer',
+ pointerEvents: 'none',
+ backgroundColor: '#d0d0d0'
+};
+
+class TooltipRail extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ value: null,
+ percent: null
+ };
+ }
+
+ onMouseEnter() {
+ document.addEventListener('mousemove', this.onMouseMove);
+ }
+
+ onMouseLeave() {
+ this.setState({ value: null, percent: null });
+ document.removeEventListener('mousemove', this.onMouseMove);
+ }
+
+ onMouseMove(e) {
+ const { activeHandleID, getEventData } = this.props;
+
+ if (activeHandleID) {
+ this.setState({ value: null, percent: null });
+ } else {
+ this.setState(getEventData(e));
+ }
+ }
+
+ render() {
+ const { value, percent } = this.state;
+ const { activeHandleID, getRailProps } = this.props;
+
+ return (
+ <>
+ {!activeHandleID && value ? (
+
+
+
+ Value:
+ {value}
+
+
+
+ ) : null}
+
+
+ >
+ );
+ }
+}
+
+TooltipRail.defaultProps = {
+ getEventData: undefined,
+ activeHandleID: undefined,
+ disabled: false
+};
+
+TooltipRail.propTypes = {
+ getEventData: PropType.func,
+ activeHandleID: PropType.string,
+ getRailProps: PropType.func.isRequired,
+ disabled: PropType.bool
+};
+
+export default TooltipRail;
diff --git a/src/components/common/PriceRange/Track.jsx b/src/components/common/PriceRange/Track.jsx
new file mode 100644
index 0000000..2e237e5
--- /dev/null
+++ b/src/components/common/PriceRange/Track.jsx
@@ -0,0 +1,44 @@
+import PropType from 'prop-types';
+import React from 'react';
+
+const Track = ({
+ source, target, getTrackProps, disabled
+}) => (
+
+);
+
+Track.propTypes = {
+ source: PropType.shape({
+ id: PropType.string.isRequired,
+ value: PropType.number.isRequired,
+ percent: PropType.number.isRequired
+ }).isRequired,
+ target: PropType.shape({
+ id: PropType.string.isRequired,
+ value: PropType.number.isRequired,
+ percent: PropType.number.isRequired
+ }).isRequired,
+ getTrackProps: PropType.func.isRequired,
+ disabled: PropType.bool
+};
+
+Track.defaultProps = {
+ disabled: false
+};
+
+
+export default Track;
diff --git a/src/components/common/PriceRange/index.jsx b/src/components/common/PriceRange/index.jsx
new file mode 100644
index 0000000..87d04b9
--- /dev/null
+++ b/src/components/common/PriceRange/index.jsx
@@ -0,0 +1,137 @@
+import PropType from 'prop-types';
+import React, { useState } from 'react';
+import {
+ Handles, Rail, Slider, Ticks, Tracks
+} from 'react-compound-slider';
+import Handle from './Handle';
+import SliderRail from './SliderRail';
+import Tick from './Tick';
+import Track from './Track';
+
+const sliderStyle = {
+ position: 'relative',
+ width: '100%'
+};
+
+const PriceRange = ({
+ min, max, initMin, initMax, productsCount, onPriceChange
+}) => {
+ const [state, setState] = useState({
+ domain: [min, max],
+ values: [initMin || min, initMax || max],
+ update: [min, max].slice(),
+ inputMin: initMin || min,
+ inputMax: initMax || max,
+ inputError: false,
+ reversed: false
+ });
+
+ const onUpdate = (update) => {
+ setState(() => ({
+ ...state, update, inputMin: update[0], inputMax: update[1]
+ }));
+ };
+
+ const onChange = (values) => {
+ setState(() => ({
+ ...state, values, inputMin: values[0], inputMax: values[1]
+ }));
+ if (values[0] < values[1]) onPriceChange(...values);
+ };
+
+
+ const inputClassName = () => (state.inputError ? 'price-range-input price-input-error' : 'price-range-input');
+
+ return (
+
+
+
+ β
+
+
+
+
+ {({ getRailProps }) => }
+
+
+ {({ handles, activeHandleID, getHandleProps }) => (
+
+ {handles.map((handle) => (
+
+ ))}
+
+ )}
+
+
+ {({ tracks, getTrackProps }) => (
+
+ {tracks.map(({ id, source, target }) => (
+
+ ))}
+
+ )}
+
+
+ {({ ticks }) => (
+
+ {ticks.map((tick) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+PriceRange.defaultProps = {
+ initMin: undefined,
+ initMax: undefined
+};
+
+PriceRange.propTypes = {
+ initMin: PropType.number,
+ initMax: PropType.number,
+ min: PropType.number.isRequired,
+ max: PropType.number.isRequired,
+ productsCount: PropType.number.isRequired,
+ onPriceChange: PropType.func.isRequired
+};
+
+export default PriceRange;
diff --git a/src/components/common/SearchBar.jsx b/src/components/common/SearchBar.jsx
new file mode 100644
index 0000000..796fa7e
--- /dev/null
+++ b/src/components/common/SearchBar.jsx
@@ -0,0 +1,121 @@
+/* eslint-disable react/no-array-index-key */
+import { SearchOutlined } from '@ant-design/icons';
+import React, { useRef, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+import { clearRecentSearch, removeSelectedRecent } from 'redux/actions/filterActions';
+
+const SearchBar = () => {
+ const [searchInput, setSearchInput] = useState('');
+ const { filter, isLoading } = useSelector((state) => ({
+ filter: state.filter,
+ isLoading: state.app.loading
+ }));
+ const searchbarRef = useRef(null);
+ const history = useHistory();
+
+ const dispatch = useDispatch();
+ const isMobile = window.screen.width <= 800;
+
+ const onSearchChange = (e) => {
+ const val = e.target.value.trimStart();
+ setSearchInput(val);
+ };
+
+ const onKeyUp = (e) => {
+ if (e.keyCode === 13) {
+ // dispatch(setTextFilter(searchInput));
+ e.target.blur();
+ searchbarRef.current.classList.remove('is-open-recent-search');
+
+ if (isMobile) {
+ history.push('/');
+ }
+
+ history.push(`/search/${searchInput.trim().toLowerCase()}`);
+ }
+ };
+
+ const recentSearchClickHandler = (e) => {
+ const searchBar = e.target.closest('.searchbar');
+
+ if (!searchBar) {
+ searchbarRef.current.classList.remove('is-open-recent-search');
+ document.removeEventListener('click', recentSearchClickHandler);
+ }
+ };
+
+ const onFocusInput = (e) => {
+ e.target.select();
+
+ if (filter.recent.length !== 0) {
+ searchbarRef.current.classList.add('is-open-recent-search');
+ document.addEventListener('click', recentSearchClickHandler);
+ }
+ };
+
+ const onClickRecentSearch = (keyword) => {
+ // dispatch(setTextFilter(keyword));
+ searchbarRef.current.classList.remove('is-open-recent-search');
+ history.push(`/search/${keyword.trim().toLowerCase()}`);
+ };
+
+ const onClearRecent = () => {
+ dispatch(clearRecentSearch());
+ };
+
+ return (
+ <>
+
+
+
+ {filter.recent.length !== 0 && (
+
+
+
Recent Search
+
+ Clear
+
+
+ {filter.recent.map((item, index) => (
+
+
onClickRecentSearch(item)}
+ role="presentation"
+ >
+ {item}
+
+ dispatch(removeSelectedRecent(item))}
+ role="presentation"
+ >
+ X
+
+
+ ))}
+
+ )}
+
+ >
+ );
+};
+
+export default SearchBar;
diff --git a/src/components/common/SocialLogin.jsx b/src/components/common/SocialLogin.jsx
new file mode 100644
index 0000000..cffa554
--- /dev/null
+++ b/src/components/common/SocialLogin.jsx
@@ -0,0 +1,60 @@
+import { FacebookOutlined, GithubFilled, GoogleOutlined } from '@ant-design/icons';
+import PropType from 'prop-types';
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import { signInWithFacebook, signInWithGithub, signInWithGoogle } from 'redux/actions/authActions';
+
+const SocialLogin = ({ isLoading }) => {
+ const dispatch = useDispatch();
+
+ const onSignInWithGoogle = () => {
+ dispatch(signInWithGoogle());
+ };
+
+ const onSignInWithFacebook = () => {
+ dispatch(signInWithFacebook());
+ };
+
+ const onSignInWithGithub = () => {
+ dispatch(signInWithGithub());
+ };
+
+ return (
+
+
+ {/* */}
+
+ Continue with Facebook
+
+
+
+ Continue with Google
+
+
+
+ Continue with GitHub
+
+
+ );
+};
+
+SocialLogin.propTypes = {
+ isLoading: PropType.bool.isRequired
+};
+
+export default SocialLogin;
diff --git a/src/components/common/index.js b/src/components/common/index.js
new file mode 100644
index 0000000..49da568
--- /dev/null
+++ b/src/components/common/index.js
@@ -0,0 +1,18 @@
+export { default as AdminNavigation } from './AdminNavigation';
+export { default as AdminSideBar } from './AdminSidePanel';
+export { default as Badge } from './Badge';
+export { default as Boundary } from './Boundary';
+export { default as ColorChooser } from './ColorChooser';
+export { default as Filters } from './Filters';
+export { default as FiltersToggle } from './FiltersToggle';
+export { default as Footer } from './Footer';
+export { default as ImageLoader } from './ImageLoader';
+export { default as MessageDisplay } from './MessageDisplay';
+export { default as MobileNavigation } from './MobileNavigation';
+export { default as Modal } from './Modal';
+export { default as Navigation } from './Navigation';
+export { default as Preloader } from './Preloader';
+export { default as PriceRange } from './PriceRange';
+export { default as SearchBar } from './SearchBar';
+export { default as SocialLogin } from './SocialLogin';
+
diff --git a/src/components/formik/CustomColorInput.jsx b/src/components/formik/CustomColorInput.jsx
new file mode 100644
index 0000000..f4a7b66
--- /dev/null
+++ b/src/components/formik/CustomColorInput.jsx
@@ -0,0 +1,87 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+/* eslint-disable react/forbid-prop-types */
+import PropType from 'prop-types';
+import React from 'react';
+
+const InputColor = (props) => {
+ const {
+ name, form, push, remove
+ } = props;
+ const [selectedColor, setSelectedColor] = React.useState('');
+
+ const handleColorChange = (e) => {
+ const val = e.target.value;
+ setSelectedColor(val);
+ };
+
+ const handleAddSelectedColor = () => {
+ if (!form.values[name].includes(selectedColor)) {
+ push(selectedColor);
+ setSelectedColor('');
+ }
+ };
+
+ return (
+
+
+
+ {form.touched[name] && form.errors[name] ? (
+
{form.errors[name]}
+ ) : (
+
+ Available Colors
+
+ )}
+ {selectedColor && (
+ <>
+
+
+
+ Add Selected Color
+
+ >
+ )}
+
+
+
+
+
Selected Color(s)
+
+ {form.values[name]?.map((color, index) => (
+
remove(index)}
+ className="color-item color-item-deletable"
+ title={`Remove ${color}`}
+ style={{ backgroundColor: color }}
+ role="presentation"
+ />
+ ))}
+
+
+
+ );
+};
+
+InputColor.propTypes = {
+ name: PropType.string.isRequired,
+ form: PropType.shape({
+ values: PropType.object,
+ touched: PropType.object,
+ errors: PropType.object
+ }).isRequired,
+ push: PropType.func.isRequired,
+ remove: PropType.func.isRequired
+};
+
+export default InputColor;
diff --git a/src/components/formik/CustomCreatableSelect.jsx b/src/components/formik/CustomCreatableSelect.jsx
new file mode 100644
index 0000000..77da22e
--- /dev/null
+++ b/src/components/formik/CustomCreatableSelect.jsx
@@ -0,0 +1,88 @@
+/* eslint-disable react/forbid-prop-types */
+import { useField } from 'formik';
+import PropType from 'prop-types';
+import React from 'react';
+import CreatableSelect from 'react-select/creatable';
+
+const CustomCreatableSelect = (props) => {
+ const [field, meta, helpers] = useField(props);
+ const {
+ options, defaultValue, label, placeholder, isMulti, type, iid
+ } = props;
+ const { touched, error } = meta;
+ const { setValue } = helpers;
+
+ const handleChange = (newValue) => {
+ if (Array.isArray(newValue)) {
+ const arr = newValue.map((fieldKey) => fieldKey.value);
+ setValue(arr);
+ } else {
+ setValue(newValue.value);
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (type === 'number') {
+ const { key } = e.nativeEvent;
+ if (/\D/.test(key) && key !== 'Backspace') {
+ e.preventDefault();
+ }
+ }
+ };
+
+ return (
+
+ {touched && error ? (
+ {error}
+ ) : (
+ {label}
+ )}
+ ({
+ ...provided,
+ zIndex: 10
+ }),
+ container: (provided) => ({
+ ...provided, marginBottom: '1.2rem'
+ }),
+ control: (provided) => ({
+ ...provided,
+ border: touched && error ? '1px solid red' : '1px solid #cacaca'
+ })
+ }}
+ />
+
+ );
+};
+
+CustomCreatableSelect.defaultProps = {
+ isMulti: false,
+ placeholder: '',
+ iid: '',
+ options: [],
+ type: 'string'
+};
+
+CustomCreatableSelect.propTypes = {
+ options: PropType.arrayOf(PropType.object),
+ defaultValue: PropType.oneOfType([
+ PropType.object,
+ PropType.array
+ ]).isRequired,
+ label: PropType.string.isRequired,
+ placeholder: PropType.string,
+ isMulti: PropType.bool,
+ type: PropType.string,
+ iid: PropType.string
+};
+
+export default CustomCreatableSelect;
diff --git a/src/components/formik/CustomInput.jsx b/src/components/formik/CustomInput.jsx
new file mode 100644
index 0000000..27dfec0
--- /dev/null
+++ b/src/components/formik/CustomInput.jsx
@@ -0,0 +1,40 @@
+/* eslint-disable react/jsx-props-no-spreading */
+/* eslint-disable react/forbid-prop-types */
+import PropType from 'prop-types';
+import React from 'react';
+
+const CustomInput = ({
+ field, form: { touched, errors }, label, inputRef, ...props
+}) => (
+
+ {touched[field.name] && errors[field.name] ? (
+ {errors[field.name]}
+ ) : (
+ {label}
+ )}
+
+
+);
+
+CustomInput.defaultProps = {
+ inputRef: undefined
+};
+
+CustomInput.propTypes = {
+ label: PropType.string.isRequired,
+ field: PropType.object.isRequired,
+ form: PropType.object.isRequired,
+ inputRef: PropType.oneOfType([
+ PropType.func,
+ PropType.shape({ current: PropType.instanceOf(Element) })
+ ])
+};
+
+export default CustomInput;
diff --git a/src/components/formik/CustomMobileInput.jsx b/src/components/formik/CustomMobileInput.jsx
new file mode 100644
index 0000000..e78a307
--- /dev/null
+++ b/src/components/formik/CustomMobileInput.jsx
@@ -0,0 +1,58 @@
+/* eslint-disable react/forbid-prop-types */
+import { useField } from 'formik';
+import PropType from 'prop-types';
+import React from 'react';
+import PhoneInput from 'react-phone-input-2';
+
+const CustomMobileInput = (props) => {
+ const [field, meta, helpers] = useField(props);
+ const { label, placeholder, defaultValue } = props;
+ const { touched, error } = meta;
+ const { setValue } = helpers;
+
+ const handleChange = (value, data) => {
+ const mob = {
+ dialCode: data.dialCode,
+ countryCode: data.countryCode,
+ country: data.name,
+ value
+ };
+
+ setValue(mob);
+ };
+
+ return (
+
+ {touched && error ? (
+
{error?.value || error?.dialCode}
+ ) : (
+
{label}
+ )}
+
+
+ );
+};
+
+CustomMobileInput.defaultProps = {
+ label: 'Mobile Number',
+ placeholder: '09254461351'
+};
+
+CustomMobileInput.propTypes = {
+ label: PropType.string,
+ placeholder: PropType.string,
+ defaultValue: PropType.object.isRequired
+};
+
+export default CustomMobileInput;
diff --git a/src/components/formik/CustomTextarea.jsx b/src/components/formik/CustomTextarea.jsx
new file mode 100644
index 0000000..6225568
--- /dev/null
+++ b/src/components/formik/CustomTextarea.jsx
@@ -0,0 +1,33 @@
+/* eslint-disable react/jsx-props-no-spreading */
+/* eslint-disable react/forbid-prop-types */
+import PropType from 'prop-types';
+import React from 'react';
+
+const CustomTextarea = ({
+ field, form: { touched, errors }, label, ...props
+}) => (
+
+ {touched[field.name] && errors[field.name] ? (
+ {errors[field.name]}
+ ) : (
+ {label}
+ )}
+
+
+);
+
+CustomTextarea.propTypes = {
+ label: PropType.string.isRequired,
+ field: PropType.object.isRequired,
+ form: PropType.object.isRequired
+};
+
+export default CustomTextarea;
diff --git a/src/components/formik/index.js b/src/components/formik/index.js
new file mode 100644
index 0000000..ef367f2
--- /dev/null
+++ b/src/components/formik/index.js
@@ -0,0 +1,6 @@
+export { default as CustomColorInput } from './CustomColorInput';
+export { default as CustomCreatableSelect } from './CustomCreatableSelect';
+export { default as CustomInput } from './CustomInput';
+export { default as CustomMobileInput } from './CustomMobileInput';
+export { default as CustomTextarea } from './CustomTextarea';
+
diff --git a/src/components/product/ProductAppliedFilters.jsx b/src/components/product/ProductAppliedFilters.jsx
new file mode 100644
index 0000000..9f18bc7
--- /dev/null
+++ b/src/components/product/ProductAppliedFilters.jsx
@@ -0,0 +1,127 @@
+/* eslint-disable no-nested-ternary */
+import { CloseCircleOutlined } from '@ant-design/icons';
+import PropType from 'prop-types';
+import React from 'react';
+import { shallowEqual, useDispatch, useSelector } from 'react-redux';
+import { applyFilter } from 'redux/actions/filterActions';
+
+const ProductAppliedFilters = ({ filteredProductsCount }) => {
+ const filter = useSelector((state) => state.filter, shallowEqual);
+ const fields = ['brand', 'minPrice', 'maxPrice', 'sortBy', 'keyword'];
+ const isFiltered = fields.some((key) => !!filter[key]);
+ const dispatch = useDispatch();
+
+ const onRemoveKeywordFilter = () => {
+ dispatch(applyFilter({ keyword: '' }));
+ };
+
+ const onRemovePriceRangeFilter = () => {
+ dispatch(applyFilter({ minPrice: 0, maxPrice: 0 }));
+ };
+
+ const onRemoveBrandFilter = () => {
+ dispatch(applyFilter({ brand: '' }));
+ };
+
+ const onRemoveSortFilter = () => {
+ dispatch(applyFilter({ sortBy: '' }));
+ };
+
+ return !isFiltered ? null : (
+ <>
+
+
+
+ {filteredProductsCount > 0
+ && `Found ${filteredProductsCount} ${filteredProductsCount > 1 ? 'products' : 'product'}`}
+
+
+
+
+ {filter.keyword && (
+
+
Keyword
+
+
{filter.keyword}
+
+
+
+
+
+
+
+ )}
+ {filter.brand && (
+
+
Brand
+
+
{filter.brand}
+
+
+
+
+
+
+
+ )}
+ {(!!filter.minPrice || !!filter.maxPrice) && (
+
+
Price Range
+
+
+ $
+ {filter.minPrice}
+ - $
+ {filter.maxPrice}
+
+
+
+
+
+
+
+
+ )}
+ {filter.sortBy && (
+
+
Sort By
+
+
+ {filter.sortBy === 'price-desc'
+ ? 'Price High - Low'
+ : filter.sortBy === 'price-asc'
+ ? 'Price Low - High'
+ : filter.sortBy === 'name-desc'
+ ? 'Name Z - A'
+ : 'Name A - Z'}
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+ProductAppliedFilters.defaultProps = {
+ filteredProductsCount: 0
+};
+
+ProductAppliedFilters.propTypes = {
+ filteredProductsCount: PropType.number
+};
+
+export default ProductAppliedFilters;
diff --git a/src/components/product/ProductFeatured.jsx b/src/components/product/ProductFeatured.jsx
new file mode 100644
index 0000000..c71aedd
--- /dev/null
+++ b/src/components/product/ProductFeatured.jsx
@@ -0,0 +1,46 @@
+import { ImageLoader } from 'components/common';
+import PropType from 'prop-types';
+import React from 'react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { useHistory } from 'react-router-dom';
+
+const ProductFeatured = ({ product }) => {
+ const history = useHistory();
+ const onClickItem = () => {
+ if (!product) return;
+
+ history.push(`/product/${product.id}`);
+ };
+
+ return (
+
+
+
+ {product.image ? (
+
+ ) : }
+
+
+
{product.name || }
+
+ {product.brand || }
+
+
+
+
+ );
+};
+
+ProductFeatured.propTypes = {
+ product: PropType.shape({
+ image: PropType.string,
+ name: PropType.string,
+ id: PropType.string,
+ brand: PropType.string
+ }).isRequired
+};
+
+export default ProductFeatured;
diff --git a/src/components/product/ProductGrid.jsx b/src/components/product/ProductGrid.jsx
new file mode 100644
index 0000000..4d93e55
--- /dev/null
+++ b/src/components/product/ProductGrid.jsx
@@ -0,0 +1,34 @@
+import { useBasket } from 'hooks';
+import PropType from 'prop-types';
+import React from 'react';
+import ProductItem from './ProductItem';
+
+const ProductGrid = ({ products }) => {
+ const { addToBasket, isItemOnBasket } = useBasket();
+
+ return (
+
+ {products.length === 0 ? new Array(12).fill({}).map((product, index) => (
+
+ )) : products.map((product) => (
+
+ ))}
+
+ );
+};
+
+ProductGrid.propTypes = {
+ // eslint-disable-next-line react/forbid-prop-types
+ products: PropType.array.isRequired
+};
+
+export default ProductGrid;
diff --git a/src/components/product/ProductItem.jsx b/src/components/product/ProductItem.jsx
new file mode 100644
index 0000000..63773cd
--- /dev/null
+++ b/src/components/product/ProductItem.jsx
@@ -0,0 +1,89 @@
+import { CheckOutlined } from '@ant-design/icons';
+import { ImageLoader } from 'components/common';
+import { displayMoney } from 'helpers/utils';
+import PropType from 'prop-types';
+import React from 'react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { useHistory } from 'react-router-dom';
+
+const ProductItem = ({ product, isItemOnBasket, addToBasket }) => {
+ const history = useHistory();
+
+ const onClickItem = () => {
+ if (!product) return;
+
+ if (product.id) {
+ history.push(`/product/${product.id}`);
+ }
+ };
+
+ const itemOnBasket = isItemOnBasket ? isItemOnBasket(product.id) : false;
+
+ const handleAddToBasket = () => {
+ if (addToBasket) addToBasket({ ...product, selectedSize: product.sizes[0] });
+ };
+
+ return (
+
+
+ {itemOnBasket &&
}
+
+
+ {product.image ? (
+
+ ) : }
+
+
+
+ {product.name || }
+
+
+ {product.brand || }
+
+
+ {product.price ? displayMoney(product.price) : }
+
+
+
+ {product.id && (
+
+ {itemOnBasket ? 'Remove from basket' : 'Add to basket'}
+
+ )}
+
+
+
+ );
+};
+
+ProductItem.defaultProps = {
+ isItemOnBasket: undefined,
+ addToBasket: undefined
+};
+
+ProductItem.propTypes = {
+ // eslint-disable-next-line react/forbid-prop-types
+ product: PropType.object.isRequired,
+ isItemOnBasket: PropType.func,
+ addToBasket: PropType.func
+};
+
+export default ProductItem;
diff --git a/src/components/product/ProductList.jsx b/src/components/product/ProductList.jsx
new file mode 100644
index 0000000..e12c7de
--- /dev/null
+++ b/src/components/product/ProductList.jsx
@@ -0,0 +1,82 @@
+/* eslint-disable react/forbid-prop-types */
+import { Boundary, MessageDisplay } from 'components/common';
+import PropType from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { setLoading } from 'redux/actions/miscActions';
+import { getProducts } from 'redux/actions/productActions';
+
+const ProductList = (props) => {
+ const {
+ products, filteredProducts, isLoading, requestStatus, children
+ } = props;
+ const [isFetching, setFetching] = useState(false);
+ const dispatch = useDispatch();
+
+ const fetchProducts = () => {
+ setFetching(true);
+ dispatch(getProducts(products.lastRefKey));
+ };
+
+ useEffect(() => {
+ if (products.items.length === 0 || !products.lastRefKey) {
+ fetchProducts();
+ }
+
+ window.scrollTo(0, 0);
+ return () => dispatch(setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ setFetching(false);
+ }, [products.lastRefKey]);
+
+ if (filteredProducts.length === 0 && !isLoading) {
+ return (
+
+ );
+ } if (filteredProducts.length === 0 && requestStatus) {
+ return (
+
+ );
+ }
+ return (
+
+ {children}
+ {/* Show 'Show More' button if products length is less than total products */}
+ {products.items.length < products.total && (
+
+
+ {isFetching ? 'Fetching Items...' : 'Show More Items'}
+
+
+ )}
+
+ );
+};
+
+ProductList.defaultProps = {
+ requestStatus: null
+};
+
+ProductList.propTypes = {
+ products: PropType.object.isRequired,
+ filteredProducts: PropType.array.isRequired,
+ isLoading: PropType.bool.isRequired,
+ requestStatus: PropType.string,
+ children: PropType.oneOfType([
+ PropType.arrayOf(PropType.node),
+ PropType.node
+ ]).isRequired
+};
+
+export default ProductList;
diff --git a/src/components/product/ProductSearch.jsx b/src/components/product/ProductSearch.jsx
new file mode 100644
index 0000000..e400902
--- /dev/null
+++ b/src/components/product/ProductSearch.jsx
@@ -0,0 +1,118 @@
+import { Filters } from 'components/common';
+import React, { useEffect, useRef } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+import { clearRecentSearch, removeSelectedRecent, setTextFilter } from 'redux/actions/filterActions';
+
+const ProductSearch = () => {
+ const history = useHistory();
+
+ const {
+ productsLength, filter, products, isLoading
+ } = useSelector((state) => ({
+ filter: state.filter,
+ products: state.products.items,
+ isLoading: state.app.loading,
+ productsLength: state.products.length
+ }));
+ const dispatch = useDispatch();
+ const searchInput = useRef(null);
+ let input = '';
+
+ useEffect(() => {
+ searchInput.current.focus();
+ }, []);
+
+ const onSearchChange = (e) => {
+ const val = e.target.value.trim();
+ input = val;
+
+ if (val === '' && productsLength !== 0) {
+ dispatch(setTextFilter(val));
+ history.push('/');
+ }
+ };
+
+ const onKeyUp = (e) => {
+ if (e.keyCode === 13 && productsLength !== 0) {
+ dispatch(setTextFilter(input));
+ history.push('/');
+ }
+ };
+
+ const onClearRecentSearch = () => {
+ dispatch(clearRecentSearch());
+ };
+
+ return (
+
+
+
+
+
+
Recent Searches
+
+ Clear
+
+
+ {filter.recent.map((item, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
{
+ dispatch(setTextFilter(item));
+ history.push('/');
+ }}
+ role="presentation"
+ >
+ {item}
+
+
dispatch(removeSelectedRecent(item))}
+ role="presentation"
+ >
+
+
+
+
+ ))}
+ {filter.recent.length === 0 && (
+
No recent searches
+ )}
+
+
+
Choose Filters
+
+
+
+
+
+
+ );
+};
+
+export default ProductSearch;
diff --git a/src/components/product/ProductShowcaseGrid.jsx b/src/components/product/ProductShowcaseGrid.jsx
new file mode 100644
index 0000000..d1a0d34
--- /dev/null
+++ b/src/components/product/ProductShowcaseGrid.jsx
@@ -0,0 +1,32 @@
+/* eslint-disable react/forbid-prop-types */
+import { FeaturedProduct } from 'components/product';
+import PropType from 'prop-types';
+import React from 'react';
+
+const ProductShowcase = ({ products, skeletonCount }) => (
+
+ {(products.length === 0) ? new Array(skeletonCount).fill({}).map((product, index) => (
+
+ )) : products.map((product) => (
+
+ ))}
+
+);
+
+ProductShowcase.defaultProps = {
+ skeletonCount: 4
+};
+
+ProductShowcase.propTypes = {
+ products: PropType.array.isRequired,
+ skeletonCount: PropType.number
+};
+
+export default ProductShowcase;
diff --git a/src/components/product/index.js b/src/components/product/index.js
new file mode 100644
index 0000000..b371be3
--- /dev/null
+++ b/src/components/product/index.js
@@ -0,0 +1,8 @@
+export { default as AppliedFilters } from './ProductAppliedFilters';
+export { default as FeaturedProduct } from './ProductFeatured';
+export { default as ProductGrid } from './ProductGrid';
+export { default as ProductItem } from './ProductItem';
+export { default as ProductList } from './ProductList';
+export { default as ProductSearch } from './ProductSearch';
+export { default as ProductShowcaseGrid } from './ProductShowcaseGrid';
+
diff --git a/src/constants/constants.js b/src/constants/constants.js
new file mode 100644
index 0000000..500f158
--- /dev/null
+++ b/src/constants/constants.js
@@ -0,0 +1,65 @@
+export const GET_PRODUCTS = 'GET_PRODUCTS';
+export const SEARCH_PRODUCT = 'SEARCH_PRODUCT';
+export const SEARCH_PRODUCT_SUCCESS = 'SEARCH_PRODUCT_SUCCESS';
+export const GET_PRODUCTS_SUCCESS = 'GET_PRODUCTS_SUCCESS';
+export const ADD_PRODUCT = 'ADD_PRODUCT';
+export const ADD_PRODUCT_SUCCESS = 'ADD_PRODUCT_SUCCESS';
+export const REMOVE_PRODUCT = 'REMOVE_PRODUCT';
+export const REMOVE_PRODUCT_SUCCESS = 'REMOVE_PRODUCT_SUCCESS';
+export const EDIT_PRODUCT = 'EDIT_PRODUCT';
+export const EDIT_PRODUCT_SUCCESS = 'EDIT_PRODUCT_SUCCESS';
+export const CANCEL_GET_PRODUCTS = 'CANCEL_GET_PRODUCTS';
+export const CLEAR_SEARCH_STATE = 'CLEAR_SEARCH_STATE';
+export const SET_LAST_REF_KEY = 'SET_LAST_REF_KEY';
+
+export const SET_BASKET_ITEMS = 'SET_BASKET_ITEMS';
+export const ADD_TO_BASKET = 'ADD_TO_BASKET';
+export const REMOVE_FROM_BASKET = 'REMOVE_FROM_BASKET';
+export const CLEAR_BASKET = 'CLEAR_BASKET';
+export const ADD_QTY_ITEM = 'ADD_QTY_ITEM';
+export const MINUS_QTY_ITEM = 'MINUS_QTY_ITEM';
+
+export const SET_CHECKOUT_SHIPPING_DETAILS = 'SET_CHECKOUT_SHIPPING_DETAILS';
+export const SET_CHECKOUT_PAYMENT_DETAILS = 'SET_CHECKOUT_PAYMENT_DETAILS';
+export const RESET_CHECKOUT = 'RESET_CHECKOUT';
+
+export const SIGNIN = 'SIGNIN';
+export const SIGNIN_SUCCESS = 'SIGNIN_SUCCESS';
+export const SIGNUP = 'SIGNUP';
+export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS';
+export const SIGNOUT = 'SIGNOUT';
+export const SIGNOUT_SUCCESS = 'SIGNOUT_SUCCESS';
+export const SET_AUTH_STATUS = 'SET_AUTH_STATUS';
+export const SIGNIN_WITH_GOOGLE = 'SIGNIN_WITH_GOOGLE';
+export const SIGNIN_WITH_FACEBOOK = 'SIGNIN_WITH_FACEBOOK';
+export const SIGNIN_WITH_GITHUB = 'SIGNIN_WITH_GITHUB';
+export const ON_AUTHSTATE_CHANGED = 'ON_AUTHSTATE_CHANGED';
+export const SET_AUTH_PERSISTENCE = 'SET_AUTH_PERSISTENCE';
+export const ON_AUTHSTATE_SUCCESS = 'ON_AUTHSTATE_SUCCESS';
+export const ON_AUTHSTATE_FAIL = 'ON_AUTHSTATE_FAIL';
+export const RESET_PASSWORD = 'RESET_PASSWORD';
+
+export const UPDATE_EMAIL = 'UPDATE_EMAIL';
+export const SET_PROFILE = 'SET_PROFILE';
+export const UPDATE_PROFILE = 'UPDATE_PROFILE';
+export const UPDATE_PROFILE_SUCCESS = 'UPDATE_PROFILE_SUCCESS';
+export const CLEAR_PROFILE = 'CLEAR_PROFILE';
+
+export const SET_TEXT_FILTER = 'SET_TEXT_FILTER';
+export const SET_BRAND_FILTER = 'SET_BRAND_FILTER';
+export const SET_MIN_PRICE_FILTER = 'SET_MIN_PRICE_FILTER';
+export const SET_MAX_PRICE_FILTER = 'SET_MAX_PRICE_FILTER';
+export const RESET_FILTER = 'RESET_FILTER';
+export const APPLY_FILTER = 'APPLY_FILTER';
+export const CLEAR_RECENT_SEARCH = 'CLEAR_RECENT_SEARCH';
+export const REMOVE_SELECTED_RECENT = 'REMOVE_SELECTED_RECENT';
+
+export const REGISTER_USER = 'REGISTER_USER';
+export const GET_USER = 'GET_USER';
+export const ADD_USER = 'ADD_USER';
+export const DELETE_USER = 'DELETE_USER';
+export const EDIT_USER = 'EDIT_USER';
+
+export const LOADING = 'LOADING';
+export const IS_AUTHENTICATING = 'IS_AUTHENTICATING';
+export const SET_REQUEST_STATUS = 'SET_REQUEST_STATUS';
diff --git a/src/constants/index.js b/src/constants/index.js
new file mode 100644
index 0000000..1ab0989
--- /dev/null
+++ b/src/constants/index.js
@@ -0,0 +1,3 @@
+export * as actionType from './constants';
+export * as route from './routes';
+
diff --git a/src/constants/routes.js b/src/constants/routes.js
new file mode 100644
index 0000000..7bf1153
--- /dev/null
+++ b/src/constants/routes.js
@@ -0,0 +1,20 @@
+export const HOME = '/';
+export const SHOP = '/shop';
+export const FEATURED_PRODUCTS = '/featured';
+export const RECOMMENDED_PRODUCTS = '/recommended';
+export const ACCOUNT = '/account';
+export const ACCOUNT_EDIT = '/account/edit';
+export const ADMIN_DASHBOARD = '/admin/dashboard';
+export const ADMIN_PRODUCTS = '/admin/products';
+export const ADMIN_USERS = '/admin/users';
+export const ADD_PRODUCT = '/admin/add';
+export const EDIT_PRODUCT = '/admin/edit';
+export const SEARCH = '/search/:searchKey';
+export const SIGNIN = '/signin';
+export const SIGNOUT = '/signout';
+export const SIGNUP = '/signup';
+export const FORGOT_PASSWORD = '/forgot_password';
+export const CHECKOUT_STEP_1 = '/checkout/step1';
+export const CHECKOUT_STEP_2 = '/checkout/step2';
+export const CHECKOUT_STEP_3 = '/checkout/step3';
+export const VIEW_PRODUCT = '/product/:id';
diff --git a/src/helpers/utils.js b/src/helpers/utils.js
new file mode 100644
index 0000000..cc3b63d
--- /dev/null
+++ b/src/helpers/utils.js
@@ -0,0 +1,66 @@
+/* eslint-disable no-nested-ternary */
+export const displayDate = (timestamp) => {
+ const date = new Date(timestamp);
+
+ const monthNames = [
+ 'January', 'February', 'March', 'April', 'May', 'June', 'July',
+ 'August', 'September', 'October', 'November', 'December'
+ ];
+
+ const day = date.getDate();
+ const monthIndex = date.getMonth();
+ const year = date.getFullYear();
+
+ // return day + ' ' + monthNames[monthIndex] + ' ' + year;
+ return `${monthNames[monthIndex]} ${day}, ${year}`;
+};
+
+export const displayMoney = (n) => {
+ const format = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD'
+ });
+
+ // or use toLocaleString()
+ return format.format(n);
+};
+
+export const calculateTotal = (arr) => {
+ if (!arr || arr?.length === 0) return 0;
+
+ const total = arr.reduce((acc, val) => acc + val, 0);
+
+ return total.toFixed(2);
+};
+
+export const displayActionMessage = (msg, status = 'info') => {
+ const div = document.createElement('div');
+ const span = document.createElement('span');
+
+ div.className = `toast ${status === 'info'
+ ? 'toast-info'
+ : status === 'success'
+ ? 'toast-success'
+ : 'toast-error'
+ // eslint-disable-next-line indent
+ }`;
+ span.className = 'toast-msg';
+ span.textContent = msg;
+ div.appendChild(span);
+
+
+ if (document.querySelector('.toast')) {
+ document.body.removeChild(document.querySelector('.toast'));
+ document.body.appendChild(div);
+ } else {
+ document.body.appendChild(div);
+ }
+
+ setTimeout(() => {
+ try {
+ document.body.removeChild(div);
+ } catch (e) {
+ console.log(e);
+ }
+ }, 3000);
+};
diff --git a/src/hooks/index.js b/src/hooks/index.js
new file mode 100644
index 0000000..a7699f8
--- /dev/null
+++ b/src/hooks/index.js
@@ -0,0 +1,10 @@
+export { default as useBasket } from './useBasket';
+export { default as useDidMount } from './useDidMount';
+export { default as useDocumentTitle } from './useDocumentTitle';
+export { default as useFeaturedProducts } from './useFeaturedProducts';
+export { default as useFileHandler } from './useFileHandler';
+export { default as useModal } from './useModal';
+export { default as useProduct } from './useProduct';
+export { default as useRecommendedProducts } from './useRecommendedProducts';
+export { default as useScrollTop } from './useScrollTop';
+
diff --git a/src/hooks/useBasket.js b/src/hooks/useBasket.js
new file mode 100644
index 0000000..f2d6e7d
--- /dev/null
+++ b/src/hooks/useBasket.js
@@ -0,0 +1,24 @@
+import { displayActionMessage } from 'helpers/utils';
+import { useDispatch, useSelector } from 'react-redux';
+import { addToBasket as dispatchAddToBasket, removeFromBasket } from 'redux/actions/basketActions';
+
+const useBasket = () => {
+ const { basket } = useSelector((state) => ({ basket: state.basket }));
+ const dispatch = useDispatch();
+
+ const isItemOnBasket = (id) => !!basket.find((item) => item.id === id);
+
+ const addToBasket = (product) => {
+ if (isItemOnBasket(product.id)) {
+ dispatch(removeFromBasket(product.id));
+ displayActionMessage('Item removed from basket', 'info');
+ } else {
+ dispatch(dispatchAddToBasket(product));
+ displayActionMessage('Item added to basket', 'success');
+ }
+ };
+
+ return { basket, isItemOnBasket, addToBasket };
+};
+
+export default useBasket;
diff --git a/src/hooks/useDidMount.js b/src/hooks/useDidMount.js
new file mode 100644
index 0000000..011d978
--- /dev/null
+++ b/src/hooks/useDidMount.js
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react';
+
+const useDidMount = (initState = false) => {
+ const [didMount, setDidMount] = useState(initState);
+
+ useEffect(() => {
+ setDidMount(true);
+
+ return () => {
+ setDidMount(false);
+ };
+ }, []);
+
+ return didMount;
+};
+
+export default useDidMount;
diff --git a/src/hooks/useDocumentTitle.js b/src/hooks/useDocumentTitle.js
new file mode 100644
index 0000000..3314bdd
--- /dev/null
+++ b/src/hooks/useDocumentTitle.js
@@ -0,0 +1,13 @@
+import { useLayoutEffect } from 'react';
+
+const useDocumentTitle = (title) => {
+ useLayoutEffect(() => {
+ if (title) {
+ document.title = title;
+ } else {
+ document.title = 'Salinaka - eCommerce React App';
+ }
+ }, [title]);
+};
+
+export default useDocumentTitle;
diff --git a/src/hooks/useFeaturedProducts.js b/src/hooks/useFeaturedProducts.js
new file mode 100644
index 0000000..2306d50
--- /dev/null
+++ b/src/hooks/useFeaturedProducts.js
@@ -0,0 +1,55 @@
+import { useDidMount } from 'hooks';
+import { useEffect, useState } from 'react';
+import firebase from 'services/firebase';
+
+const useFeaturedProducts = (itemsCount) => {
+ const [featuredProducts, setFeaturedProducts] = useState([]);
+ const [isLoading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const didMount = useDidMount(true);
+
+ const fetchFeaturedProducts = async () => {
+ try {
+ setLoading(true);
+ setError('');
+
+ const docs = await firebase.getFeaturedProducts(itemsCount);
+
+ if (docs.empty) {
+ if (didMount) {
+ setError('No featured products found.');
+ setLoading(false);
+ }
+ } else {
+ const items = [];
+
+ docs.forEach((snap) => {
+ const data = snap.data();
+ items.push({ id: snap.ref.id, ...data });
+ });
+
+ if (didMount) {
+ setFeaturedProducts(items);
+ setLoading(false);
+ }
+ }
+ } catch (e) {
+ if (didMount) {
+ setError('Failed to fetch featured products');
+ setLoading(false);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (featuredProducts.length === 0 && didMount) {
+ fetchFeaturedProducts();
+ }
+ }, []);
+
+ return {
+ featuredProducts, fetchFeaturedProducts, isLoading, error
+ };
+};
+
+export default useFeaturedProducts;
diff --git a/src/hooks/useFileHandler.js b/src/hooks/useFileHandler.js
new file mode 100644
index 0000000..dda8b74
--- /dev/null
+++ b/src/hooks/useFileHandler.js
@@ -0,0 +1,67 @@
+/* eslint-disable no-alert */
+import { useState } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+
+const useFileHandler = (initState) => {
+ const [imageFile, setImageFile] = useState(initState);
+ const [isFileLoading, setFileLoading] = useState(false);
+
+ const removeImage = ({ id, name }) => {
+ const items = imageFile[name].filter((item) => item.id !== id);
+
+ setImageFile({
+ ...imageFile,
+ [name]: items
+ });
+ };
+
+ const onFileChange = (event, { name, type }) => {
+ const val = event.target.value;
+ const img = event.target.files[0];
+ const size = img.size / 1024 / 1024;
+ const regex = /(\.jpg|\.jpeg|\.png)$/i;
+
+ setFileLoading(true);
+ if (!regex.exec(val)) {
+ alert('File type must be JPEG or PNG', 'error');
+ setFileLoading(false);
+ } else if (size > 0.5) {
+ alert('File size exceeded 500kb, consider optimizing your image', 'error');
+ setFileLoading(false);
+ } else if (type === 'multiple') {
+ Array.from(event.target.files).forEach((file) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', (e) => {
+ setImageFile((oldFiles) => ({
+ ...oldFiles,
+ [name]: [...oldFiles[name], { file, url: e.target.result, id: uuidv4() }]
+ }));
+ });
+ reader.readAsDataURL(file);
+ });
+
+ setFileLoading(false);
+ } else { // type is single
+ const reader = new FileReader();
+
+ reader.addEventListener('load', (e) => {
+ setImageFile({
+ ...imageFile,
+ [name]: { file: img, url: e.target.result }
+ });
+ setFileLoading(false);
+ });
+ reader.readAsDataURL(img);
+ }
+ };
+
+ return {
+ imageFile,
+ setImageFile,
+ isFileLoading,
+ onFileChange,
+ removeImage
+ };
+};
+
+export default useFileHandler;
diff --git a/src/hooks/useModal.js b/src/hooks/useModal.js
new file mode 100644
index 0000000..47a7824
--- /dev/null
+++ b/src/hooks/useModal.js
@@ -0,0 +1,17 @@
+import { useState } from 'react';
+
+const useModal = () => {
+ const [isOpenModal, setModalOpen] = useState(false);
+
+ const onOpenModal = () => {
+ setModalOpen(true);
+ };
+
+ const onCloseModal = () => {
+ setModalOpen(false);
+ };
+
+ return { isOpenModal, onOpenModal, onCloseModal };
+};
+
+export default useModal;
diff --git a/src/hooks/useProduct.js b/src/hooks/useProduct.js
new file mode 100644
index 0000000..1452dcc
--- /dev/null
+++ b/src/hooks/useProduct.js
@@ -0,0 +1,45 @@
+import { useDidMount } from 'hooks';
+import { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import firebase from 'services/firebase';
+
+const useProduct = (id) => {
+ // get and check if product exists in store
+ const storeProduct = useSelector((state) => state.products.items.find((item) => item.id === id));
+
+ const [product, setProduct] = useState(storeProduct);
+ const [isLoading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const didMount = useDidMount(true);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ if (!product || product.id !== id) {
+ setLoading(true);
+ const doc = await firebase.getSingleProduct(id);
+
+ if (doc.exists) {
+ const data = { ...doc.data(), id: doc.ref.id };
+
+ if (didMount) {
+ setProduct(data);
+ setLoading(false);
+ }
+ } else {
+ setError('Product not found.');
+ }
+ }
+ } catch (err) {
+ if (didMount) {
+ setLoading(false);
+ setError(err?.message || 'Something went wrong.');
+ }
+ }
+ })();
+ }, [id]);
+
+ return { product, isLoading, error };
+};
+
+export default useProduct;
diff --git a/src/hooks/useRecommendedProducts.js b/src/hooks/useRecommendedProducts.js
new file mode 100644
index 0000000..060354a
--- /dev/null
+++ b/src/hooks/useRecommendedProducts.js
@@ -0,0 +1,56 @@
+import { useDidMount } from 'hooks';
+import { useEffect, useState } from 'react';
+import firebase from '../services/firebase';
+
+const useRecommendedProducts = (itemsCount) => {
+ const [recommendedProducts, setRecommendedProducts] = useState([]);
+ const [isLoading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const didMount = useDidMount(true);
+
+ const fetchRecommendedProducts = async () => {
+ try {
+ setLoading(true);
+ setError('');
+
+ const docs = await firebase.getRecommendedProducts(itemsCount);
+
+ if (docs.empty) {
+ if (didMount) {
+ setError('No recommended products found.');
+ setLoading(false);
+ }
+ } else {
+ const items = [];
+
+ docs.forEach((snap) => {
+ const data = snap.data();
+ items.push({ id: snap.ref.id, ...data });
+ });
+
+ if (didMount) {
+ setRecommendedProducts(items);
+ setLoading(false);
+ }
+ }
+ } catch (e) {
+ if (didMount) {
+ setError('Failed to fetch recommended products');
+ setLoading(false);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (recommendedProducts.length === 0 && didMount) {
+ fetchRecommendedProducts();
+ }
+ }, []);
+
+
+ return {
+ recommendedProducts, fetchRecommendedProducts, isLoading, error
+ };
+};
+
+export default useRecommendedProducts;
diff --git a/src/hooks/useScrollTop.js b/src/hooks/useScrollTop.js
new file mode 100644
index 0000000..2c9e48b
--- /dev/null
+++ b/src/hooks/useScrollTop.js
@@ -0,0 +1,9 @@
+import { useEffect } from 'react';
+
+const useScrollTop = () => {
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, []);
+};
+
+export default useScrollTop;
diff --git a/src/images/NIKE AIR MAX (1).jpeg b/src/images/NIKE AIR MAX (1).jpeg
new file mode 100644
index 0000000..eb0b30f
Binary files /dev/null and b/src/images/NIKE AIR MAX (1).jpeg differ
diff --git a/src/images/banner-girl-1.png b/src/images/banner-girl-1.png
new file mode 100644
index 0000000..4ba66b0
Binary files /dev/null and b/src/images/banner-girl-1.png differ
diff --git a/src/images/banner-girl.png b/src/images/banner-girl.png
new file mode 100644
index 0000000..b900c61
Binary files /dev/null and b/src/images/banner-girl.png differ
diff --git a/src/images/banner-guy.png b/src/images/banner-guy.png
new file mode 100644
index 0000000..327b4da
Binary files /dev/null and b/src/images/banner-guy.png differ
diff --git a/src/images/creditcards.png b/src/images/creditcards.png
new file mode 100644
index 0000000..9a27fca
Binary files /dev/null and b/src/images/creditcards.png differ
diff --git a/src/images/defaultAvatar.jpg b/src/images/defaultAvatar.jpg
new file mode 100644
index 0000000..af1a74a
Binary files /dev/null and b/src/images/defaultAvatar.jpg differ
diff --git a/src/images/defaultBanner.jpg b/src/images/defaultBanner.jpg
new file mode 100644
index 0000000..acb211e
Binary files /dev/null and b/src/images/defaultBanner.jpg differ
diff --git a/src/images/logo-full.png b/src/images/logo-full.png
new file mode 100644
index 0000000..a3b23e9
Binary files /dev/null and b/src/images/logo-full.png differ
diff --git a/src/images/logo-wordmark.png b/src/images/logo-wordmark.png
new file mode 100644
index 0000000..e66f4e3
Binary files /dev/null and b/src/images/logo-wordmark.png differ
diff --git a/src/images/square.jpg b/src/images/square.jpg
new file mode 100644
index 0000000..ac6e9b4
Binary files /dev/null and b/src/images/square.jpg differ
diff --git a/src/index.jsx b/src/index.jsx
new file mode 100644
index 0000000..eb187cd
--- /dev/null
+++ b/src/index.jsx
@@ -0,0 +1,43 @@
+import { Preloader } from 'components/common';
+import 'normalize.css/normalize.css';
+import React from 'react';
+import { render } from 'react-dom';
+import 'react-phone-input-2/lib/style.css';
+import { onAuthStateFail, onAuthStateSuccess } from 'redux/actions/authActions';
+import configureStore from 'redux/store/store';
+import 'styles/style.scss';
+import WebFont from 'webfontloader';
+import App from './App';
+import firebase from './services/firebase';
+
+WebFont.load({
+ google: {
+ families: ['Tajawal']
+ }
+});
+
+const { store, persistor } = configureStore();
+const root = document.getElementById('app');
+
+// Render the preloader on initial load
+render(
, root);
+
+firebase.auth.onAuthStateChanged((user) => {
+ if (user) {
+ store.dispatch(onAuthStateSuccess(user));
+ } else {
+ store.dispatch(onAuthStateFail('Failed to authenticate'));
+ }
+ // then render the app after checking the auth state
+ render(
, root);
+});
+
+if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').then((registration) => {
+ console.log('SW registered: ', registration);
+ }).catch((registrationError) => {
+ console.log('SW registration failed: ', registrationError);
+ });
+ });
+}
diff --git a/src/redux/actions/authActions.js b/src/redux/actions/authActions.js
new file mode 100644
index 0000000..7edf4d2
--- /dev/null
+++ b/src/redux/actions/authActions.js
@@ -0,0 +1,63 @@
+import * as type from 'constants/constants';
+
+export const signIn = (email, password) => ({
+ type: type.SIGNIN,
+ payload: {
+ email,
+ password
+ }
+});
+
+export const signInWithGoogle = () => ({
+ type: type.SIGNIN_WITH_GOOGLE
+});
+
+export const signInWithFacebook = () => ({
+ type: type.SIGNIN_WITH_FACEBOOK
+});
+
+export const signInWithGithub = () => ({
+ type: type.SIGNIN_WITH_GITHUB
+});
+
+export const signUp = (user) => ({
+ type: type.SIGNUP,
+ payload: user
+});
+
+export const signInSuccess = (auth) => ({
+ type: type.SIGNIN_SUCCESS,
+ payload: auth
+});
+
+export const setAuthPersistence = () => ({
+ type: type.SET_AUTH_PERSISTENCE
+});
+
+export const signOut = () => ({
+ type: type.SIGNOUT
+});
+
+export const signOutSuccess = () => ({
+ type: type.SIGNOUT_SUCCESS
+});
+
+export const onAuthStateChanged = () => ({
+ type: type.ON_AUTHSTATE_CHANGED
+});
+
+export const onAuthStateSuccess = (user) => ({
+ type: type.ON_AUTHSTATE_SUCCESS,
+ payload: user
+});
+
+export const onAuthStateFail = (error) => ({
+ type: type.ON_AUTHSTATE_FAIL,
+ payload: error
+});
+
+export const resetPassword = (email) => ({
+ type: type.RESET_PASSWORD,
+ payload: email
+});
+
diff --git a/src/redux/actions/basketActions.js b/src/redux/actions/basketActions.js
new file mode 100644
index 0000000..18ecd79
--- /dev/null
+++ b/src/redux/actions/basketActions.js
@@ -0,0 +1,35 @@
+import {
+ ADD_QTY_ITEM, ADD_TO_BASKET,
+ CLEAR_BASKET,
+ MINUS_QTY_ITEM, REMOVE_FROM_BASKET,
+ SET_BASKET_ITEMS
+} from 'constants/constants';
+
+export const setBasketItems = (items = []) => ({
+ type: SET_BASKET_ITEMS,
+ payload: items
+});
+
+export const addToBasket = (product) => ({
+ type: ADD_TO_BASKET,
+ payload: product
+});
+
+export const removeFromBasket = (id) => ({
+ type: REMOVE_FROM_BASKET,
+ payload: id
+});
+
+export const clearBasket = () => ({
+ type: CLEAR_BASKET
+});
+
+export const addQtyItem = (id) => ({
+ type: ADD_QTY_ITEM,
+ payload: id
+});
+
+export const minusQtyItem = (id) => ({
+ type: MINUS_QTY_ITEM,
+ payload: id
+});
diff --git a/src/redux/actions/checkoutActions.js b/src/redux/actions/checkoutActions.js
new file mode 100644
index 0000000..ebe5ce3
--- /dev/null
+++ b/src/redux/actions/checkoutActions.js
@@ -0,0 +1,17 @@
+import {
+ RESET_CHECKOUT, SET_CHECKOUT_PAYMENT_DETAILS, SET_CHECKOUT_SHIPPING_DETAILS
+} from 'constants/constants';
+
+export const setShippingDetails = (details) => ({
+ type: SET_CHECKOUT_SHIPPING_DETAILS,
+ payload: details
+});
+
+export const setPaymentDetails = (details) => ({
+ type: SET_CHECKOUT_PAYMENT_DETAILS,
+ payload: details
+});
+
+export const resetCheckout = () => ({
+ type: RESET_CHECKOUT
+});
diff --git a/src/redux/actions/filterActions.js b/src/redux/actions/filterActions.js
new file mode 100644
index 0000000..236daf5
--- /dev/null
+++ b/src/redux/actions/filterActions.js
@@ -0,0 +1,45 @@
+import {
+ APPLY_FILTER,
+ CLEAR_RECENT_SEARCH,
+ REMOVE_SELECTED_RECENT, RESET_FILTER, SET_BRAND_FILTER,
+ SET_MAX_PRICE_FILTER,
+ SET_MIN_PRICE_FILTER, SET_TEXT_FILTER
+} from 'constants/constants';
+
+export const setTextFilter = (keyword) => ({
+ type: SET_TEXT_FILTER,
+ payload: keyword
+});
+
+export const setBrandFilter = (brand) => ({
+ type: SET_BRAND_FILTER,
+ payload: brand
+});
+
+export const setMinPriceFilter = (min) => ({
+ type: SET_MIN_PRICE_FILTER,
+ payload: min
+});
+
+export const setMaxPriceFilter = (max) => ({
+ type: SET_MAX_PRICE_FILTER,
+ payload: max
+});
+
+export const resetFilter = () => ({
+ type: RESET_FILTER
+});
+
+export const clearRecentSearch = () => ({
+ type: CLEAR_RECENT_SEARCH
+});
+
+export const removeSelectedRecent = (keyword) => ({
+ type: REMOVE_SELECTED_RECENT,
+ payload: keyword
+});
+
+export const applyFilter = (filters) => ({
+ type: APPLY_FILTER,
+ payload: filters
+});
diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js
new file mode 100644
index 0000000..f5c9981
--- /dev/null
+++ b/src/redux/actions/index.js
@@ -0,0 +1,9 @@
+export * as authActions from './authActions';
+export * as basketActions from './basketActions';
+export * as checkoutActions from './checkoutActions';
+export * as filterActions from './filterActions';
+export * as miscActions from './miscActions';
+export * as productActions from './productActions';
+export * as profileActions from './profileActions';
+export * as userActions from './userActions';
+
diff --git a/src/redux/actions/miscActions.js b/src/redux/actions/miscActions.js
new file mode 100644
index 0000000..a11f5e1
--- /dev/null
+++ b/src/redux/actions/miscActions.js
@@ -0,0 +1,24 @@
+import {
+ IS_AUTHENTICATING, LOADING, SET_AUTH_STATUS, SET_REQUEST_STATUS
+} from 'constants/constants';
+
+export const setLoading = (bool = true) => ({
+ type: LOADING,
+ payload: bool
+});
+
+export const setAuthenticating = (bool = true) => ({
+ type: IS_AUTHENTICATING,
+ payload: bool
+});
+
+export const setRequestStatus = (status) => ({
+ type: SET_REQUEST_STATUS,
+ payload: status
+});
+
+
+export const setAuthStatus = (status = null) => ({
+ type: SET_AUTH_STATUS,
+ payload: status
+});
diff --git a/src/redux/actions/productActions.js b/src/redux/actions/productActions.js
new file mode 100644
index 0000000..372601c
--- /dev/null
+++ b/src/redux/actions/productActions.js
@@ -0,0 +1,77 @@
+import {
+ ADD_PRODUCT,
+ ADD_PRODUCT_SUCCESS,
+ CANCEL_GET_PRODUCTS,
+ CLEAR_SEARCH_STATE,
+ EDIT_PRODUCT,
+ EDIT_PRODUCT_SUCCESS,
+ GET_PRODUCTS,
+ GET_PRODUCTS_SUCCESS,
+ REMOVE_PRODUCT,
+ REMOVE_PRODUCT_SUCCESS,
+ SEARCH_PRODUCT,
+ SEARCH_PRODUCT_SUCCESS
+} from 'constants/constants';
+
+export const getProducts = (lastRef) => ({
+ type: GET_PRODUCTS,
+ payload: lastRef
+});
+
+export const getProductsSuccess = (products) => ({
+ type: GET_PRODUCTS_SUCCESS,
+ payload: products
+});
+
+export const cancelGetProducts = () => ({
+ type: CANCEL_GET_PRODUCTS
+});
+
+export const addProduct = (product) => ({
+ type: ADD_PRODUCT,
+ payload: product
+});
+
+export const searchProduct = (searchKey) => ({
+ type: SEARCH_PRODUCT,
+ payload: {
+ searchKey
+ }
+});
+
+export const searchProductSuccess = (products) => ({
+ type: SEARCH_PRODUCT_SUCCESS,
+ payload: products
+});
+
+export const clearSearchState = () => ({
+ type: CLEAR_SEARCH_STATE
+});
+
+export const addProductSuccess = (product) => ({
+ type: ADD_PRODUCT_SUCCESS,
+ payload: product
+});
+
+export const removeProduct = (id) => ({
+ type: REMOVE_PRODUCT,
+ payload: id
+});
+
+export const removeProductSuccess = (id) => ({
+ type: REMOVE_PRODUCT_SUCCESS,
+ payload: id
+});
+
+export const editProduct = (id, updates) => ({
+ type: EDIT_PRODUCT,
+ payload: {
+ id,
+ updates
+ }
+});
+
+export const editProductSuccess = (updates) => ({
+ type: EDIT_PRODUCT_SUCCESS,
+ payload: updates
+});
diff --git a/src/redux/actions/profileActions.js b/src/redux/actions/profileActions.js
new file mode 100644
index 0000000..5548aa4
--- /dev/null
+++ b/src/redux/actions/profileActions.js
@@ -0,0 +1,38 @@
+import {
+ CLEAR_PROFILE,
+ SET_PROFILE,
+ UPDATE_EMAIL,
+ UPDATE_PROFILE,
+ UPDATE_PROFILE_SUCCESS
+} from 'constants/constants';
+
+export const clearProfile = () => ({
+ type: CLEAR_PROFILE
+});
+
+export const setProfile = (user) => ({
+ type: SET_PROFILE,
+ payload: user
+});
+
+export const updateEmail = (password, newEmail) => ({
+ type: UPDATE_EMAIL,
+ payload: {
+ password,
+ newEmail
+ }
+});
+
+export const updateProfile = (newProfile) => ({
+ type: UPDATE_PROFILE,
+ payload: {
+ updates: newProfile.updates,
+ files: newProfile.files,
+ credentials: newProfile.credentials
+ }
+});
+
+export const updateProfileSuccess = (updates) => ({
+ type: UPDATE_PROFILE_SUCCESS,
+ payload: updates
+});
diff --git a/src/redux/actions/userActions.js b/src/redux/actions/userActions.js
new file mode 100644
index 0000000..7240d7a
--- /dev/null
+++ b/src/redux/actions/userActions.js
@@ -0,0 +1,32 @@
+import {
+ ADD_USER,
+
+ DELETE_USER, EDIT_USER, GET_USER, REGISTER_USER
+} from 'constants/constants';
+
+// insert in profile array
+export const registerUser = (user) => ({
+ type: REGISTER_USER,
+ payload: user
+});
+
+export const getUser = (uid) => ({
+ type: GET_USER,
+ payload: uid
+});
+
+// different from registerUser -- only inserted in admins' users array not in profile array
+export const addUser = (user) => ({
+ type: ADD_USER,
+ payload: user
+});
+
+export const editUser = (updates) => ({
+ type: EDIT_USER,
+ payload: updates
+});
+
+export const deleteUser = (id) => ({
+ type: DELETE_USER,
+ payload: id
+});
diff --git a/src/redux/reducers/authReducer.js b/src/redux/reducers/authReducer.js
new file mode 100644
index 0000000..bacaf22
--- /dev/null
+++ b/src/redux/reducers/authReducer.js
@@ -0,0 +1,23 @@
+import { SIGNIN_SUCCESS, SIGNOUT_SUCCESS } from 'constants/constants';
+
+const initState = null;
+// {
+// id: 'test-123',
+// role: 'ADMIN',
+// provider: 'password'
+// };
+
+export default (state = initState, action) => {
+ switch (action.type) {
+ case SIGNIN_SUCCESS:
+ return {
+ id: action.payload.id,
+ role: action.payload.role,
+ provider: action.payload.provider
+ };
+ case SIGNOUT_SUCCESS:
+ return null;
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/basketReducer.js b/src/redux/reducers/basketReducer.js
new file mode 100644
index 0000000..5a2e84d
--- /dev/null
+++ b/src/redux/reducers/basketReducer.js
@@ -0,0 +1,43 @@
+import {
+ ADD_QTY_ITEM, ADD_TO_BASKET,
+ CLEAR_BASKET,
+ MINUS_QTY_ITEM, REMOVE_FROM_BASKET,
+ SET_BASKET_ITEMS
+} from 'constants/constants';
+
+export default (state = [], action) => {
+ switch (action.type) {
+ case SET_BASKET_ITEMS:
+ return action.payload;
+ case ADD_TO_BASKET:
+ return state.some((product) => product.id === action.payload.id)
+ ? state
+ : [action.payload, ...state];
+ case REMOVE_FROM_BASKET:
+ return state.filter((product) => product.id !== action.payload);
+ case CLEAR_BASKET:
+ return [];
+ case ADD_QTY_ITEM:
+ return state.map((product) => {
+ if (product.id === action.payload) {
+ return {
+ ...product,
+ quantity: product.quantity + 1
+ };
+ }
+ return product;
+ });
+ case MINUS_QTY_ITEM:
+ return state.map((product) => {
+ if (product.id === action.payload) {
+ return {
+ ...product,
+ quantity: product.quantity - 1
+ };
+ }
+ return product;
+ });
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/checkoutReducer.js b/src/redux/reducers/checkoutReducer.js
new file mode 100644
index 0000000..aa0795d
--- /dev/null
+++ b/src/redux/reducers/checkoutReducer.js
@@ -0,0 +1,33 @@
+import {
+ RESET_CHECKOUT, SET_CHECKOUT_PAYMENT_DETAILS, SET_CHECKOUT_SHIPPING_DETAILS
+} from 'constants/constants';
+
+const defaultState = {
+ shipping: {},
+ payment: {
+ type: 'paypal',
+ name: '',
+ cardnumber: '',
+ expiry: '',
+ ccv: ''
+ }
+};
+
+export default (state = defaultState, action) => {
+ switch (action.type) {
+ case SET_CHECKOUT_SHIPPING_DETAILS:
+ return {
+ ...state,
+ shipping: action.payload
+ };
+ case SET_CHECKOUT_PAYMENT_DETAILS:
+ return {
+ ...state,
+ payment: action.payload
+ };
+ case RESET_CHECKOUT:
+ return defaultState;
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/filterReducer.js b/src/redux/reducers/filterReducer.js
new file mode 100644
index 0000000..9763072
--- /dev/null
+++ b/src/redux/reducers/filterReducer.js
@@ -0,0 +1,61 @@
+import {
+ APPLY_FILTER,
+ CLEAR_RECENT_SEARCH,
+ REMOVE_SELECTED_RECENT, RESET_FILTER, SET_BRAND_FILTER,
+ SET_MAX_PRICE_FILTER,
+ SET_MIN_PRICE_FILTER, SET_TEXT_FILTER
+} from 'constants/constants';
+
+const initState = {
+ recent: [],
+ keyword: '',
+ brand: '',
+ minPrice: 0,
+ maxPrice: 0,
+ sortBy: ''
+};
+
+export default (state = initState, action) => {
+ switch (action.type) {
+ case SET_TEXT_FILTER:
+ return {
+ ...state,
+ recent: (!!state.recent.find((n) => n === action.payload) || action.payload === '') ? state.recent : [action.payload, ...state.recent],
+ keyword: action.payload
+ };
+ case SET_BRAND_FILTER:
+ return {
+ ...state,
+ brand: action.payload
+ };
+ case SET_MAX_PRICE_FILTER:
+ return {
+ ...state,
+ maxPrice: action.payload
+ };
+ case SET_MIN_PRICE_FILTER:
+ return {
+ ...state,
+ minPrice: action.payload
+ };
+ case RESET_FILTER:
+ return initState;
+ case CLEAR_RECENT_SEARCH:
+ return {
+ ...state,
+ recent: []
+ };
+ case REMOVE_SELECTED_RECENT:
+ return {
+ ...state,
+ recent: state.recent.filter((item) => item !== action.payload)
+ };
+ case APPLY_FILTER:
+ return {
+ ...state,
+ ...action.payload
+ };
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js
new file mode 100644
index 0000000..be49724
--- /dev/null
+++ b/src/redux/reducers/index.js
@@ -0,0 +1,21 @@
+import authReducer from './authReducer';
+import basketReducer from './basketReducer';
+import checkoutReducer from './checkoutReducer';
+import filterReducer from './filterReducer';
+import miscReducer from './miscReducer';
+import productReducer from './productReducer';
+import profileReducer from './profileReducer';
+import userReducer from './userReducer';
+
+const rootReducer = {
+ products: productReducer,
+ basket: basketReducer,
+ auth: authReducer,
+ profile: profileReducer,
+ filter: filterReducer,
+ users: userReducer,
+ checkout: checkoutReducer,
+ app: miscReducer
+};
+
+export default rootReducer;
diff --git a/src/redux/reducers/miscReducer.js b/src/redux/reducers/miscReducer.js
new file mode 100644
index 0000000..92a3205
--- /dev/null
+++ b/src/redux/reducers/miscReducer.js
@@ -0,0 +1,40 @@
+import {
+ IS_AUTHENTICATING, LOADING,
+ SET_AUTH_STATUS,
+ SET_REQUEST_STATUS
+} from 'constants/constants';
+
+const initState = {
+ loading: false,
+ isAuthenticating: false,
+ authStatus: null,
+ requestStatus: null,
+ theme: 'light'
+};
+
+export default (state = initState, action) => {
+ switch (action.type) {
+ case LOADING:
+ return {
+ ...state,
+ loading: action.payload
+ };
+ case IS_AUTHENTICATING:
+ return {
+ ...state,
+ isAuthenticating: action.payload
+ };
+ case SET_REQUEST_STATUS:
+ return {
+ ...state,
+ requestStatus: action.payload
+ };
+ case SET_AUTH_STATUS:
+ return {
+ ...state,
+ authStatus: action.payload
+ };
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/productReducer.js b/src/redux/reducers/productReducer.js
new file mode 100644
index 0000000..39acc32
--- /dev/null
+++ b/src/redux/reducers/productReducer.js
@@ -0,0 +1,68 @@
+import {
+ ADD_PRODUCT_SUCCESS,
+ CLEAR_SEARCH_STATE, EDIT_PRODUCT_SUCCESS,
+ GET_PRODUCTS_SUCCESS, REMOVE_PRODUCT_SUCCESS,
+ SEARCH_PRODUCT_SUCCESS
+} from 'constants/constants';
+
+const initState = {
+ lastRefKey: null,
+ total: 0,
+ items: []
+};
+
+export default (state = {
+ lastRefKey: null,
+ total: 0,
+ items: [],
+ searchedProducts: initState
+}, action) => {
+ switch (action.type) {
+ case GET_PRODUCTS_SUCCESS:
+ return {
+ ...state,
+ lastRefKey: action.payload.lastKey,
+ total: action.payload.total,
+ items: [...state.items, ...action.payload.products]
+ };
+ case ADD_PRODUCT_SUCCESS:
+ return {
+ ...state,
+ items: [...state.items, action.payload]
+ };
+ case SEARCH_PRODUCT_SUCCESS:
+ return {
+ ...state,
+ searchedProducts: {
+ lastRefKey: action.payload.lastKey,
+ total: action.payload.total,
+ items: [...state.searchedProducts.items, ...action.payload.products]
+ }
+ };
+ case CLEAR_SEARCH_STATE:
+ return {
+ ...state,
+ searchedProducts: initState
+ };
+ case REMOVE_PRODUCT_SUCCESS:
+ return {
+ ...state,
+ items: state.items.filter((product) => product.id !== action.payload)
+ };
+ case EDIT_PRODUCT_SUCCESS:
+ return {
+ ...state,
+ items: state.items.map((product) => {
+ if (product.id === action.payload.id) {
+ return {
+ ...product,
+ ...action.payload.updates
+ };
+ }
+ return product;
+ })
+ };
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/profileReducer.js b/src/redux/reducers/profileReducer.js
new file mode 100644
index 0000000..185d4e2
--- /dev/null
+++ b/src/redux/reducers/profileReducer.js
@@ -0,0 +1,29 @@
+import { CLEAR_PROFILE, SET_PROFILE, UPDATE_PROFILE_SUCCESS } from 'constants/constants';
+// import profile from 'static/profile.jpg';
+// import banner from 'static/banner.jpg';
+
+// const initState = {
+// fullname: 'Pedro Juan',
+// email: 'juanpedro@gmail.com',
+// address: '',
+// mobile: {},
+// avatar: profile,
+// banner,
+// dateJoined: 1954234787348
+// };
+
+export default (state = {}, action) => {
+ switch (action.type) {
+ case SET_PROFILE:
+ return action.payload;
+ case UPDATE_PROFILE_SUCCESS:
+ return {
+ ...state,
+ ...action.payload
+ };
+ case CLEAR_PROFILE:
+ return {};
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/reducers/userReducer.js b/src/redux/reducers/userReducer.js
new file mode 100644
index 0000000..0d7fd94
--- /dev/null
+++ b/src/redux/reducers/userReducer.js
@@ -0,0 +1,34 @@
+import { ADD_USER, DELETE_USER, EDIT_USER } from 'constants/constants';
+
+// const initState = [
+// {
+// firstname: 'Gago',
+// lastname: 'Ka',
+// email: 'gagoka@mail.com',
+// password: 'gagooo',
+// avatar: '',
+// banner: '',
+// dateJoined: 0
+// }
+// ];
+
+export default (state = {}, action) => {
+ switch (action.type) {
+ case ADD_USER:
+ return [...state, action.payload];
+ case EDIT_USER:
+ return state.map((user) => {
+ if (user.id === action.payload.id) {
+ return {
+ ...user,
+ ...action.payload
+ };
+ }
+ return user;
+ });
+ case DELETE_USER:
+ return state.filter((user) => user.id !== action.payload);
+ default:
+ return state;
+ }
+};
diff --git a/src/redux/sagas/authSaga.js b/src/redux/sagas/authSaga.js
new file mode 100644
index 0000000..af6fdf1
--- /dev/null
+++ b/src/redux/sagas/authSaga.js
@@ -0,0 +1,207 @@
+import {
+ ON_AUTHSTATE_FAIL,
+ ON_AUTHSTATE_SUCCESS, RESET_PASSWORD,
+ SET_AUTH_PERSISTENCE,
+ SIGNIN, SIGNIN_WITH_FACEBOOK,
+ SIGNIN_WITH_GITHUB, SIGNIN_WITH_GOOGLE,
+ SIGNOUT, SIGNUP
+} from 'constants/constants';
+import { SIGNIN as ROUTE_SIGNIN } from 'constants/routes';
+import defaultAvatar from 'images/defaultAvatar.jpg';
+import defaultBanner from 'images/defaultBanner.jpg';
+import { call, put } from 'redux-saga/effects';
+import { signInSuccess, signOutSuccess } from 'redux/actions/authActions';
+import { clearBasket, setBasketItems } from 'redux/actions/basketActions';
+import { resetCheckout } from 'redux/actions/checkoutActions';
+import { resetFilter } from 'redux/actions/filterActions';
+import { setAuthenticating, setAuthStatus } from 'redux/actions/miscActions';
+import { clearProfile, setProfile } from 'redux/actions/profileActions';
+import { history } from 'routers/AppRouter';
+import firebase from 'services/firebase';
+
+function* handleError(e) {
+ const obj = { success: false, type: 'auth', isError: true };
+ yield put(setAuthenticating(false));
+
+ switch (e.code) {
+ case 'auth/network-request-failed':
+ yield put(setAuthStatus({ ...obj, message: 'Network error has occured. Please try again.' }));
+ break;
+ case 'auth/email-already-in-use':
+ yield put(setAuthStatus({ ...obj, message: 'Email is already in use. Please use another email' }));
+ break;
+ case 'auth/wrong-password':
+ yield put(setAuthStatus({ ...obj, message: 'Incorrect email or password' }));
+ break;
+ case 'auth/user-not-found':
+ yield put(setAuthStatus({ ...obj, message: 'Incorrect email or password' }));
+ break;
+ case 'auth/reset-password-error':
+ yield put(setAuthStatus({ ...obj, message: 'Failed to send password reset email. Did you type your email correctly?' }));
+ break;
+ default:
+ yield put(setAuthStatus({ ...obj, message: e.message }));
+ break;
+ }
+}
+
+function* initRequest() {
+ yield put(setAuthenticating());
+ yield put(setAuthStatus({}));
+}
+
+function* authSaga({ type, payload }) {
+ switch (type) {
+ case SIGNIN:
+ try {
+ yield initRequest();
+ yield call(firebase.signIn, payload.email, payload.password);
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ case SIGNIN_WITH_GOOGLE:
+ try {
+ yield initRequest();
+ yield call(firebase.signInWithGoogle);
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ case SIGNIN_WITH_FACEBOOK:
+ try {
+ yield initRequest();
+ yield call(firebase.signInWithFacebook);
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ case SIGNIN_WITH_GITHUB:
+ try {
+ yield initRequest();
+ yield call(firebase.signInWithGithub);
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ case SIGNUP:
+ try {
+ yield initRequest();
+
+ const ref = yield call(firebase.createAccount, payload.email, payload.password);
+ const fullname = payload.fullname.split(' ').map((name) => name[0].toUpperCase().concat(name.substring(1))).join(' ');
+ const user = {
+ fullname,
+ avatar: defaultAvatar,
+ banner: defaultBanner,
+ email: payload.email,
+ address: '',
+ basket: [],
+ mobile: { data: {} },
+ role: 'USER',
+ dateJoined: ref.user.metadata.creationTime || new Date().getTime()
+ };
+
+ yield call(firebase.addUser, ref.user.uid, user);
+ yield put(setProfile(user));
+ yield put(setAuthenticating(false));
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ case SIGNOUT: {
+ try {
+ yield initRequest();
+ yield call(firebase.signOut);
+ yield put(clearBasket());
+ yield put(clearProfile());
+ yield put(resetFilter());
+ yield put(resetCheckout());
+ yield put(signOutSuccess());
+ yield put(setAuthenticating(false));
+ yield call(history.push, ROUTE_SIGNIN);
+ } catch (e) {
+ console.log(e);
+ }
+ break;
+ }
+ case RESET_PASSWORD: {
+ try {
+ yield initRequest();
+ yield call(firebase.passwordReset, payload);
+ yield put(setAuthStatus({
+ success: true,
+ type: 'reset',
+ message: 'Password reset email has been sent to your provided email.'
+ }));
+ yield put(setAuthenticating(false));
+ } catch (e) {
+ handleError({ code: 'auth/reset-password-error' });
+ }
+ break;
+ }
+ case ON_AUTHSTATE_SUCCESS: {
+ const snapshot = yield call(firebase.getUser, payload.uid);
+
+ if (snapshot.data()) { // if user exists in database
+ const user = snapshot.data();
+
+ yield put(setProfile(user));
+ yield put(setBasketItems(user.basket));
+ yield put(setBasketItems(user.basket));
+ yield put(signInSuccess({
+ id: payload.uid,
+ role: user.role,
+ provider: payload.providerData[0].providerId
+ }));
+ } else if (payload.providerData[0].providerId !== 'password' && !snapshot.data()) {
+ // add the user if auth provider is not password
+ const user = {
+ fullname: payload.displayName ? payload.displayName : 'User',
+ avatar: payload.photoURL ? payload.photoURL : defaultAvatar,
+ banner: defaultBanner,
+ email: payload.email,
+ address: '',
+ basket: [],
+ mobile: { data: {} },
+ role: 'USER',
+ dateJoined: payload.metadata.creationTime
+ };
+ yield call(firebase.addUser, payload.uid, user);
+ yield put(setProfile(user));
+ yield put(signInSuccess({
+ id: payload.uid,
+ role: user.role,
+ provider: payload.providerData[0].providerId
+ }));
+ }
+
+ yield put(setAuthStatus({
+ success: true,
+ type: 'auth',
+ isError: false,
+ message: 'Successfully signed in. Redirecting...'
+ }));
+ yield put(setAuthenticating(false));
+ break;
+ }
+ case ON_AUTHSTATE_FAIL: {
+ yield put(clearProfile());
+ yield put(signOutSuccess());
+ break;
+ }
+ case SET_AUTH_PERSISTENCE: {
+ try {
+ yield call(firebase.setAuthPersistence);
+ } catch (e) {
+ console.log(e);
+ }
+ break;
+ }
+ default: {
+ throw new Error('Unexpected Action Type.');
+ }
+ }
+}
+
+export default authSaga;
diff --git a/src/redux/sagas/productSaga.js b/src/redux/sagas/productSaga.js
new file mode 100644
index 0000000..0154afb
--- /dev/null
+++ b/src/redux/sagas/productSaga.js
@@ -0,0 +1,208 @@
+/* eslint-disable indent */
+import {
+ ADD_PRODUCT,
+ EDIT_PRODUCT,
+ GET_PRODUCTS,
+ REMOVE_PRODUCT,
+ SEARCH_PRODUCT
+} from 'constants/constants';
+import { ADMIN_PRODUCTS } from 'constants/routes';
+import { displayActionMessage } from 'helpers/utils';
+import {
+ all, call, put, select
+} from 'redux-saga/effects';
+import { setLoading, setRequestStatus } from 'redux/actions/miscActions';
+import { history } from 'routers/AppRouter';
+import firebase from 'services/firebase';
+import {
+ addProductSuccess,
+ clearSearchState, editProductSuccess, getProductsSuccess,
+ removeProductSuccess,
+ searchProductSuccess
+} from '../actions/productActions';
+
+function* initRequest() {
+ yield put(setLoading(true));
+ yield put(setRequestStatus(null));
+}
+
+function* handleError(e) {
+ yield put(setLoading(false));
+ yield put(setRequestStatus(e?.message || 'Failed to fetch products'));
+ console.log('ERROR: ', e);
+}
+
+function* handleAction(location, message, status) {
+ if (location) yield call(history.push, location);
+ yield call(displayActionMessage, message, status);
+}
+
+function* productSaga({ type, payload }) {
+ switch (type) {
+ case GET_PRODUCTS:
+ try {
+ yield initRequest();
+ const state = yield select();
+ const result = yield call(firebase.getProducts, payload);
+
+ if (result.products.length === 0) {
+ handleError('No items found.');
+ } else {
+ yield put(getProductsSuccess({
+ products: result.products,
+ lastKey: result.lastKey ? result.lastKey : state.products.lastRefKey,
+ total: result.total ? result.total : state.products.total
+ }));
+ yield put(setRequestStatus(''));
+ }
+ // yield put({ type: SET_LAST_REF_KEY, payload: result.lastKey });
+ yield put(setLoading(false));
+ } catch (e) {
+ console.log(e);
+ yield handleError(e);
+ }
+ break;
+
+ case ADD_PRODUCT: {
+ try {
+ yield initRequest();
+
+ const { imageCollection } = payload;
+ const key = yield call(firebase.generateKey);
+ const downloadURL = yield call(firebase.storeImage, key, 'products', payload.image);
+ const image = { id: key, url: downloadURL };
+ let images = [];
+
+ if (imageCollection.length !== 0) {
+ const imageKeys = yield all(imageCollection.map(() => firebase.generateKey));
+ const imageUrls = yield all(imageCollection.map((img, i) => firebase.storeImage(imageKeys[i](), 'products', img.file)));
+ images = imageUrls.map((url, i) => ({
+ id: imageKeys[i](),
+ url
+ }));
+ }
+
+ const product = {
+ ...payload,
+ image: downloadURL,
+ imageCollection: [image, ...images]
+ };
+
+ yield call(firebase.addProduct, key, product);
+ yield put(addProductSuccess({
+ id: key,
+ ...product
+ }));
+ yield handleAction(ADMIN_PRODUCTS, 'Item succesfully added', 'success');
+ yield put(setLoading(false));
+ } catch (e) {
+ yield handleError(e);
+ yield handleAction(undefined, `Item failed to add: ${e?.message}`, 'error');
+ }
+ break;
+ }
+ case EDIT_PRODUCT: {
+ try {
+ yield initRequest();
+
+ const { image, imageCollection } = payload.updates;
+ let newUpdates = { ...payload.updates };
+
+ if (image.constructor === File && typeof image === 'object') {
+ try {
+ yield call(firebase.deleteImage, payload.id);
+ } catch (e) {
+ console.error('Failed to delete image ', e);
+ }
+
+ const url = yield call(firebase.storeImage, payload.id, 'products', image);
+ newUpdates = { ...newUpdates, image: url };
+ }
+
+ if (imageCollection.length > 1) {
+ const existingUploads = [];
+ const newUploads = [];
+
+ imageCollection.forEach((img) => {
+ if (img.file) {
+ newUploads.push(img);
+ } else {
+ existingUploads.push(img);
+ }
+ });
+
+ const imageKeys = yield all(newUploads.map(() => firebase.generateKey));
+ const imageUrls = yield all(newUploads.map((img, i) => firebase.storeImage(imageKeys[i](), 'products', img.file)));
+ const images = imageUrls.map((url, i) => ({
+ id: imageKeys[i](),
+ url
+ }));
+ newUpdates = { ...newUpdates, imageCollection: [...existingUploads, ...images] };
+ } else {
+ newUpdates = {
+ ...newUpdates,
+ imageCollection: [{ id: new Date().getTime(), url: newUpdates.image }]
+ };
+ // add image thumbnail to image collection from newUpdates to
+ // make sure you're adding the url not the file object.
+ }
+
+ yield call(firebase.editProduct, payload.id, newUpdates);
+ yield put(editProductSuccess({
+ id: payload.id,
+ updates: newUpdates
+ }));
+ yield handleAction(ADMIN_PRODUCTS, 'Item succesfully edited', 'success');
+ yield put(setLoading(false));
+ } catch (e) {
+ yield handleError(e);
+ yield handleAction(undefined, `Item failed to edit: ${e.message}`, 'error');
+ }
+ break;
+ }
+ case REMOVE_PRODUCT: {
+ try {
+ yield initRequest();
+ yield call(firebase.removeProduct, payload);
+ yield put(removeProductSuccess(payload));
+ yield put(setLoading(false));
+ yield handleAction(ADMIN_PRODUCTS, 'Item succesfully removed', 'success');
+ } catch (e) {
+ yield handleError(e);
+ yield handleAction(undefined, `Item failed to remove: ${e.message}`, 'error');
+ }
+ break;
+ }
+ case SEARCH_PRODUCT: {
+ try {
+ yield initRequest();
+ // clear search data
+ yield put(clearSearchState());
+
+ const state = yield select();
+ const result = yield call(firebase.searchProducts, payload.searchKey);
+
+ if (result.products.length === 0) {
+ yield handleError({ message: 'No product found.' });
+ yield put(clearSearchState());
+ } else {
+ yield put(searchProductSuccess({
+ products: result.products,
+ lastKey: result.lastKey ? result.lastKey : state.products.searchedProducts.lastRefKey,
+ total: result.total ? result.total : state.products.searchedProducts.total
+ }));
+ yield put(setRequestStatus(''));
+ }
+ yield put(setLoading(false));
+ } catch (e) {
+ yield handleError(e);
+ }
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected action type ${type}`);
+ }
+ }
+}
+
+export default productSaga;
diff --git a/src/redux/sagas/profileSaga.js b/src/redux/sagas/profileSaga.js
new file mode 100644
index 0000000..8778671
--- /dev/null
+++ b/src/redux/sagas/profileSaga.js
@@ -0,0 +1,71 @@
+import { UPDATE_EMAIL, UPDATE_PROFILE } from 'constants/constants';
+import { ACCOUNT } from 'constants/routes';
+import { displayActionMessage } from 'helpers/utils';
+import { call, put, select } from 'redux-saga/effects';
+import { history } from 'routers/AppRouter';
+import firebase from 'services/firebase';
+import { setLoading } from '../actions/miscActions';
+import { updateProfileSuccess } from '../actions/profileActions';
+
+function* profileSaga({ type, payload }) {
+ switch (type) {
+ case UPDATE_EMAIL: {
+ try {
+ yield put(setLoading(false));
+ yield call(firebase.updateEmail, payload.password, payload.newEmail);
+
+ yield put(setLoading(false));
+ yield call(history.push, '/profile');
+ yield call(displayActionMessage, 'Email Updated Successfully!', 'success');
+ } catch (e) {
+ console.log(e.message);
+ }
+ break;
+ }
+ case UPDATE_PROFILE: {
+ try {
+ const state = yield select();
+ const { email, password } = payload.credentials;
+ const { avatarFile, bannerFile } = payload.files;
+
+ yield put(setLoading(true));
+
+ // if email & password exist && the email has been edited
+ // update the email
+ if (email && password && email !== state.profile.email) {
+ yield call(firebase.updateEmail, password, email);
+ }
+
+ if (avatarFile || bannerFile) {
+ const bannerURL = bannerFile ? yield call(firebase.storeImage, state.auth.id, 'banner', bannerFile) : payload.updates.banner;
+ const avatarURL = avatarFile ? yield call(firebase.storeImage, state.auth.id, 'avatar', avatarFile) : payload.updates.avatar;
+ const updates = { ...payload.updates, avatar: avatarURL, banner: bannerURL };
+
+ yield call(firebase.updateProfile, state.auth.id, updates);
+ yield put(updateProfileSuccess(updates));
+ } else {
+ yield call(firebase.updateProfile, state.auth.id, payload.updates);
+ yield put(updateProfileSuccess(payload.updates));
+ }
+
+ yield put(setLoading(false));
+ yield call(history.push, ACCOUNT);
+ yield call(displayActionMessage, 'Profile Updated Successfully!', 'success');
+ } catch (e) {
+ console.log(e);
+ yield put(setLoading(false));
+ if (e.code === 'auth/wrong-password') {
+ yield call(displayActionMessage, 'Wrong password, profile update failed :(', 'error');
+ } else {
+ yield call(displayActionMessage, `:( Failed to update profile. ${e.message ? e.message : ''}`, 'error');
+ }
+ }
+ break;
+ }
+ default: {
+ throw new Error('Unexpected action type.');
+ }
+ }
+}
+
+export default profileSaga;
diff --git a/src/redux/sagas/rootSaga.js b/src/redux/sagas/rootSaga.js
new file mode 100644
index 0000000..ec963d9
--- /dev/null
+++ b/src/redux/sagas/rootSaga.js
@@ -0,0 +1,34 @@
+import * as ACTION from 'constants/constants';
+import { takeLatest } from 'redux-saga/effects';
+import authSaga from './authSaga';
+import productSaga from './productSaga';
+import profileSaga from './profileSaga';
+
+function* rootSaga() {
+ yield takeLatest([
+ ACTION.SIGNIN,
+ ACTION.SIGNUP,
+ ACTION.SIGNOUT,
+ ACTION.SIGNIN_WITH_GOOGLE,
+ ACTION.SIGNIN_WITH_FACEBOOK,
+ ACTION.SIGNIN_WITH_GITHUB,
+ ACTION.ON_AUTHSTATE_CHANGED,
+ ACTION.ON_AUTHSTATE_SUCCESS,
+ ACTION.ON_AUTHSTATE_FAIL,
+ ACTION.SET_AUTH_PERSISTENCE,
+ ACTION.RESET_PASSWORD
+ ], authSaga);
+ yield takeLatest([
+ ACTION.ADD_PRODUCT,
+ ACTION.SEARCH_PRODUCT,
+ ACTION.REMOVE_PRODUCT,
+ ACTION.EDIT_PRODUCT,
+ ACTION.GET_PRODUCTS
+ ], productSaga);
+ yield takeLatest([
+ ACTION.UPDATE_EMAIL,
+ ACTION.UPDATE_PROFILE
+ ], profileSaga);
+}
+
+export default rootSaga;
diff --git a/src/redux/store/store.js b/src/redux/store/store.js
new file mode 100644
index 0000000..6c9436f
--- /dev/null
+++ b/src/redux/store/store.js
@@ -0,0 +1,28 @@
+import {
+ applyMiddleware,
+ compose, createStore
+} from 'redux';
+import { persistCombineReducers, persistStore } from 'redux-persist';
+import storage from 'redux-persist/lib/storage';
+import createSagaMiddleware from 'redux-saga';
+import rootReducer from '../reducers';
+import rootSaga from '../sagas/rootSaga';
+
+const sagaMiddleware = createSagaMiddleware();
+const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+
+const authPersistConfig = {
+ key: 'root',
+ storage,
+ whitelist: ['auth', 'profile', 'basket', 'checkout']
+};
+
+export default () => {
+ const store = createStore(
+ persistCombineReducers(authPersistConfig, rootReducer),
+ composeEnhancer(applyMiddleware(sagaMiddleware))
+ );
+ const persistor = persistStore(store);
+ sagaMiddleware.run(rootSaga);
+ return { store, persistor };
+};
diff --git a/src/routers/AdminRoute.jsx b/src/routers/AdminRoute.jsx
new file mode 100644
index 0000000..11d16a8
--- /dev/null
+++ b/src/routers/AdminRoute.jsx
@@ -0,0 +1,48 @@
+/* eslint-disable react/forbid-prop-types */
+/* eslint-disable react/jsx-props-no-spreading */
+import { AdminNavigation, AdminSideBar } from 'components/common';
+import PropType from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Redirect, Route } from 'react-router-dom';
+
+const AdminRoute = ({
+ isAuth, role, component: Component, ...rest
+}) => (
+
(
+ isAuth && role === 'ADMIN' ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) :
+ )}
+ />
+);
+
+const mapStateToProps = ({ auth }) => ({
+ isAuth: !!auth,
+ role: auth?.role || ''
+});
+
+AdminRoute.defaultProps = {
+ isAuth: false,
+ role: 'USER'
+};
+
+AdminRoute.propTypes = {
+ isAuth: PropType.bool,
+ role: PropType.string,
+ component: PropType.func.isRequired,
+ // eslint-disable-next-line react/require-default-props
+ rest: PropType.any
+};
+
+export default connect(mapStateToProps)(AdminRoute);
diff --git a/src/routers/AppRouter.jsx b/src/routers/AppRouter.jsx
new file mode 100644
index 0000000..d1c6d04
--- /dev/null
+++ b/src/routers/AppRouter.jsx
@@ -0,0 +1,110 @@
+import { Basket } from 'components/basket';
+import { Footer, Navigation } from 'components/common';
+import * as ROUTES from 'constants/routes';
+import { createBrowserHistory } from 'history';
+import React from 'react';
+import { Route, Router, Switch } from 'react-router-dom';
+import * as view from 'views';
+import AdminRoute from './AdminRoute';
+import ClientRoute from './ClientRoute';
+import PublicRoute from './PublicRoute';
+
+// Revert back to history v4.10.0 because
+// v5.0 breaks navigation
+export const history = createBrowserHistory();
+
+const AppRouter = () => (
+
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+);
+
+export default AppRouter;
diff --git a/src/routers/ClientRoute.jsx b/src/routers/ClientRoute.jsx
new file mode 100644
index 0000000..2638e00
--- /dev/null
+++ b/src/routers/ClientRoute.jsx
@@ -0,0 +1,58 @@
+/* eslint-disable react/forbid-prop-types */
+/* eslint-disable react/jsx-props-no-spreading */
+/* eslint-disable no-nested-ternary */
+import { ADMIN_DASHBOARD, SIGNIN } from 'constants/routes';
+import PropType from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Redirect, Route } from 'react-router-dom';
+
+const PrivateRoute = ({
+ isAuth, role, component: Component, ...rest
+}) => (
+ {
+ if (isAuth && role === 'USER') {
+ return (
+
+
+
+ );
+ }
+
+ if (isAuth && role === 'ADMIN') {
+ return ;
+ }
+
+ return (
+
+ );
+ }}
+ />
+);
+
+PrivateRoute.defaultProps = {
+ isAuth: false,
+ role: 'USER'
+};
+
+PrivateRoute.propTypes = {
+ isAuth: PropType.bool,
+ role: PropType.string,
+ component: PropType.func.isRequired,
+ // eslint-disable-next-line react/require-default-props
+ rest: PropType.any
+};
+
+const mapStateToProps = ({ auth }) => ({
+ isAuth: !!auth,
+ role: auth?.role || ''
+});
+
+export default connect(mapStateToProps)(PrivateRoute);
diff --git a/src/routers/PublicRoute.jsx b/src/routers/PublicRoute.jsx
new file mode 100644
index 0000000..b5ea8d7
--- /dev/null
+++ b/src/routers/PublicRoute.jsx
@@ -0,0 +1,56 @@
+/* eslint-disable react/forbid-prop-types */
+/* eslint-disable react/jsx-props-no-spreading */
+import { ADMIN_DASHBOARD, SIGNIN, SIGNUP } from 'constants/routes';
+import PropType from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Redirect, Route } from 'react-router-dom';
+
+const PublicRoute = ({
+ isAuth, role, component: Component, path, ...rest
+}) => (
+ {
+ // eslint-disable-next-line react/prop-types
+ const { from } = props.location.state || { from: { pathname: '/' } };
+
+ if (isAuth && role === 'ADMIN') {
+ return ;
+ }
+
+ if ((isAuth && role === 'USER') && (path === SIGNIN || path === SIGNUP)) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+ }}
+ />
+);
+
+PublicRoute.defaultProps = {
+ isAuth: false,
+ role: 'USER',
+ path: '/'
+};
+
+PublicRoute.propTypes = {
+ isAuth: PropType.bool,
+ role: PropType.string,
+ component: PropType.func.isRequired,
+ path: PropType.string,
+ // eslint-disable-next-line react/require-default-props
+ rest: PropType.any
+};
+
+const mapStateToProps = ({ auth }) => ({
+ isAuth: !!auth,
+ role: auth?.role || ''
+});
+
+export default connect(mapStateToProps)(PublicRoute);
diff --git a/src/selectors/selector.js b/src/selectors/selector.js
new file mode 100644
index 0000000..5c4efdb
--- /dev/null
+++ b/src/selectors/selector.js
@@ -0,0 +1,60 @@
+/* eslint-disable no-plusplus */
+/* eslint-disable no-else-return */
+export const selectFilter = (products, filter) => {
+ if (!products || products.length === 0) return [];
+
+ const keyword = filter.keyword.toLowerCase();
+
+ return products.filter((product) => {
+ const isInRange = filter.maxPrice
+ ? (product.price >= filter.minPrice && product.price <= filter.maxPrice)
+ : true;
+ const matchKeyword = product.keywords ? product.keywords.includes(keyword) : true;
+ // const matchName = product.name ? product.name.toLowerCase().includes(keyword) : true;
+ const matchDescription = product.description
+ ? product.description.toLowerCase().includes(keyword)
+ : true;
+ const matchBrand = product.brand ? product.brand.toLowerCase().includes(filter.brand) : true;
+
+ return ((matchKeyword || matchDescription) && matchBrand && isInRange);
+ }).sort((a, b) => {
+ if (filter.sortBy === 'name-desc') {
+ return a.name < b.name ? 1 : -1;
+ } else if (filter.sortBy === 'name-asc') {
+ return a.name > b.name ? 1 : -1;
+ } else if (filter.sortBy === 'price-desc') {
+ return a.price < b.price ? 1 : -1;
+ }
+
+ return a.price > b.price ? 1 : -1;
+ });
+};
+
+// Select product with highest price
+export const selectMax = (products) => {
+ if (!products || products.length === 0) return 0;
+
+ let high = products[0];
+
+ for (let i = 0; i < products.length; i++) {
+ if (products[i].price > high.price) {
+ high = products[i];
+ }
+ }
+
+ return Math.floor(high.price);
+};
+
+// Select product with lowest price
+export const selectMin = (products) => {
+ if (!products || products.length === 0) return 0;
+ let low = products[0];
+
+ for (let i = 0; i < products.length; i++) {
+ if (products[i].price < low.price) {
+ low = products[i];
+ }
+ }
+
+ return Math.floor(low.price);
+};
diff --git a/src/services/config.js b/src/services/config.js
new file mode 100644
index 0000000..a5d61ac
--- /dev/null
+++ b/src/services/config.js
@@ -0,0 +1,12 @@
+const firebaseConfig = {
+ apiKey: "AIzaSyD-DtZYBOmkrhsiUhhgzW7_ZOdlBL7icq0",
+ authDomain: "peppy-castle-307907.firebaseapp.com",
+ databaseURL: "https://peppy-castle-307907-default-rtdb.firebaseio.com",
+ projectId: "peppy-castle-307907",
+ storageBucket: "peppy-castle-307907.appspot.com",
+ messagingSenderId: "295760600830",
+ appId: "1:295760600830:web:3b3c2ac425713d612fcfaf",
+ measurementId: "G-ZBPD77EVH2"
+};
+
+export default firebaseConfig;
diff --git a/src/services/firebase.js b/src/services/firebase.js
new file mode 100644
index 0000000..e30626c
--- /dev/null
+++ b/src/services/firebase.js
@@ -0,0 +1,269 @@
+import app from "firebase/app";
+import "firebase/auth";
+import "firebase/firestore";
+import "firebase/storage";
+import firebaseConfig from "./config";
+
+class Firebase {
+ constructor() {
+ app.initializeApp(firebaseConfig);
+
+ this.storage = app.storage();
+ this.db = app.firestore();
+ this.auth = app.auth();
+ }
+
+ // AUTH ACTIONS ------------
+
+ createAccount = (email, password) =>
+ this.auth.createUserWithEmailAndPassword(email, password);
+
+ signIn = (email, password) =>
+ this.auth.signInWithEmailAndPassword(email, password);
+
+ signInWithGoogle = () =>
+ this.auth.signInWithPopup(new app.auth.GoogleAuthProvider());
+
+ signInWithFacebook = () =>
+ this.auth.signInWithPopup(new app.auth.FacebookAuthProvider());
+
+ signInWithGithub = () =>
+ this.auth.signInWithPopup(new app.auth.GithubAuthProvider());
+
+ signOut = () => this.auth.signOut();
+
+ passwordReset = (email) => this.auth.sendPasswordResetEmail(email);
+
+ addUser = (id, user) => this.db.collection("users").doc(id).set(user);
+
+ getUser = (id) => this.db.collection("users").doc(id).get();
+
+ passwordUpdate = (password) => this.auth.currentUser.updatePassword(password);
+
+ changePassword = (currentPassword, newPassword) =>
+ new Promise((resolve, reject) => {
+ this.reauthenticate(currentPassword)
+ .then(() => {
+ const user = this.auth.currentUser;
+ user
+ .updatePassword(newPassword)
+ .then(() => {
+ resolve("Password updated successfully!");
+ })
+ .catch((error) => reject(error));
+ })
+ .catch((error) => reject(error));
+ });
+
+ reauthenticate = (currentPassword) => {
+ const user = this.auth.currentUser;
+ const cred = app.auth.EmailAuthProvider.credential(
+ user.email,
+ currentPassword
+ );
+
+ return user.reauthenticateWithCredential(cred);
+ };
+
+ updateEmail = (currentPassword, newEmail) =>
+ new Promise((resolve, reject) => {
+ this.reauthenticate(currentPassword)
+ .then(() => {
+ const user = this.auth.currentUser;
+ user
+ .updateEmail(newEmail)
+ .then(() => {
+ resolve("Email Successfully updated");
+ })
+ .catch((error) => reject(error));
+ })
+ .catch((error) => reject(error));
+ });
+
+ updateProfile = (id, updates) =>
+ this.db.collection("users").doc(id).update(updates);
+
+ onAuthStateChanged = () =>
+ new Promise((resolve, reject) => {
+ this.auth.onAuthStateChanged((user) => {
+ if (user) {
+ resolve(user);
+ } else {
+ reject(new Error("Auth State Changed failed"));
+ }
+ });
+ });
+
+ saveBasketItems = (items, userId) =>
+ this.db.collection("users").doc(userId).update({ basket: items });
+
+ setAuthPersistence = () =>
+ this.auth.setPersistence(app.auth.Auth.Persistence.LOCAL);
+
+ // // PRODUCT ACTIONS --------------
+
+ getSingleProduct = (id) => this.db.collection("products").doc(id).get();
+
+ getProducts = (lastRefKey) => {
+ let didTimeout = false;
+
+ return new Promise((resolve, reject) => {
+ (async () => {
+ if (lastRefKey) {
+ try {
+ const query = this.db
+ .collection("products")
+ .orderBy(app.firestore.FieldPath.documentId())
+ .startAfter(lastRefKey)
+ .limit(12);
+
+ const snapshot = await query.get();
+ const products = [];
+ snapshot.forEach((doc) =>
+ products.push({ id: doc.id, ...doc.data() })
+ );
+ const lastKey = snapshot.docs[snapshot.docs.length - 1];
+
+ resolve({ products, lastKey });
+ } catch (e) {
+ reject(e?.message || ":( Failed to fetch products.");
+ }
+ } else {
+ const timeout = setTimeout(() => {
+ didTimeout = true;
+ reject(new Error("Request timeout, please try again"));
+ }, 15000);
+
+ try {
+ const totalQuery = await this.db.collection("products").get();
+ const total = totalQuery.docs.length;
+ const query = this.db
+ .collection("products")
+ .orderBy(app.firestore.FieldPath.documentId())
+ .limit(12);
+ const snapshot = await query.get();
+
+ clearTimeout(timeout);
+ if (!didTimeout) {
+ const products = [];
+ snapshot.forEach((doc) =>
+ products.push({ id: doc.id, ...doc.data() })
+ );
+ const lastKey = snapshot.docs[snapshot.docs.length - 1];
+
+ resolve({ products, lastKey, total });
+ }
+ } catch (e) {
+ if (didTimeout) return;
+ reject(e?.message || ":( Failed to fetch products.");
+ }
+ }
+ })();
+ });
+ };
+
+ searchProducts = (searchKey) => {
+ let didTimeout = false;
+
+ return new Promise((resolve, reject) => {
+ (async () => {
+ const productsRef = this.db.collection("products");
+
+ const timeout = setTimeout(() => {
+ didTimeout = true;
+ reject(new Error("Request timeout, please try again"));
+ }, 15000);
+
+ try {
+ const searchedNameRef = productsRef
+ .orderBy("name_lower")
+ .where("name_lower", ">=", searchKey)
+ .where("name_lower", "<=", `${searchKey}\uf8ff`)
+ .limit(12);
+ const searchedKeywordsRef = productsRef
+ .orderBy("dateAdded", "desc")
+ .where("keywords", "array-contains-any", searchKey.split(" "))
+ .limit(12);
+
+ // const totalResult = await totalQueryRef.get();
+ const nameSnaps = await searchedNameRef.get();
+ const keywordsSnaps = await searchedKeywordsRef.get();
+ // const total = totalResult.docs.length;
+
+ clearTimeout(timeout);
+ if (!didTimeout) {
+ const searchedNameProducts = [];
+ const searchedKeywordsProducts = [];
+ let lastKey = null;
+
+ if (!nameSnaps.empty) {
+ nameSnaps.forEach((doc) => {
+ searchedNameProducts.push({ id: doc.id, ...doc.data() });
+ });
+ lastKey = nameSnaps.docs[nameSnaps.docs.length - 1];
+ }
+
+ if (!keywordsSnaps.empty) {
+ keywordsSnaps.forEach((doc) => {
+ searchedKeywordsProducts.push({ id: doc.id, ...doc.data() });
+ });
+ }
+
+ // MERGE PRODUCTS
+ const mergedProducts = [
+ ...searchedNameProducts,
+ ...searchedKeywordsProducts,
+ ];
+ const hash = {};
+
+ mergedProducts.forEach((product) => {
+ hash[product.id] = product;
+ });
+
+ resolve({ products: Object.values(hash), lastKey });
+ }
+ } catch (e) {
+ if (didTimeout) return;
+ reject(e);
+ }
+ })();
+ });
+ };
+
+ getFeaturedProducts = (itemsCount = 12) =>
+ this.db
+ .collection("products")
+ .where("isFeatured", "==", true)
+ .limit(itemsCount)
+ .get();
+
+ getRecommendedProducts = (itemsCount = 12) =>
+ this.db
+ .collection("products")
+ .where("isRecommended", "==", true)
+ .limit(itemsCount)
+ .get();
+
+ addProduct = (id, product) =>
+ this.db.collection("products").doc(id).set(product);
+
+ generateKey = () => this.db.collection("products").doc().id;
+
+ storeImage = async (id, folder, imageFile) => {
+ const snapshot = await this.storage.ref(folder).child(id).put(imageFile);
+ const downloadURL = await snapshot.ref.getDownloadURL();
+
+ return downloadURL;
+ };
+
+ deleteImage = (id) => this.storage.ref("products").child(id).delete();
+
+ editProduct = (id, updates) =>
+ this.db.collection("products").doc(id).update(updates);
+
+ removeProduct = (id) => this.db.collection("products").doc(id).delete();
+}
+
+const firebaseInstance = new Firebase();
+
+export default firebaseInstance;
diff --git a/src/styles/1 - settings/_breakpoints.scss b/src/styles/1 - settings/_breakpoints.scss
new file mode 100644
index 0000000..865b452
--- /dev/null
+++ b/src/styles/1 - settings/_breakpoints.scss
@@ -0,0 +1,14 @@
+//
+// BREAKPOINTS ---------
+//
+:root {
+ --mobile: 43rem;
+}
+
+
+// Breakpoints
+$mobile: 30rem;
+$tablet: 50rem;
+$laptop: 64rem;
+$desktop: 95rem;
+$l-desktop: 102rem;
\ No newline at end of file
diff --git a/src/styles/1 - settings/_colors.scss b/src/styles/1 - settings/_colors.scss
new file mode 100644
index 0000000..c3c79fd
--- /dev/null
+++ b/src/styles/1 - settings/_colors.scss
@@ -0,0 +1,51 @@
+//
+// COLORS ---------
+//
+
+:root {
+ --nav-bg: #f8f8f8;
+ --nav-bg-scrolled: #fff;
+ --nav-bg-shadow: 0 5px 10px rgba(0, 0, 0, .02);
+}
+
+$nav-bg: #e7e7e7;
+$nav-bg-scrolled: rgb(255, 255, 255);
+
+
+$background-color: #fff0fa;
+$background-color-01: #f3f3f3;
+
+// Paragraphs
+$paragraph-color: #4a4a4a; // base
+
+// Heading
+$heading-color: #000000;
+
+// Border
+$border-color: #575656;
+$border-color-focus: #c5c5c5;
+
+// General
+$white: rgb(255, 255, 255);
+$black: #000;
+$off-black: #303030;
+$off-white: #f0f0f0;
+$red: rgba(247, 45, 45, 0.986);
+$green: rgb(59, 150, 32);
+$yellow: rgb(228, 165, 31);
+$gray-01: #3a3a3a;
+$gray-10: #818181;
+$gray-20: #b6b6b6;
+
+// Buttons
+$button-color: #0663f0;
+// $button-hover: lighten($button-color, 10%);
+$button-hover: linear-gradient(45deg, rgb(253, 112, 112), rgb(252, 252, 143), rgb(130, 130, 252), rgb(250, 126, 126));
+
+// Social
+$color-facebook: #0078ff;
+$color-facebook-hover: darken($color-facebook, 5%);
+$color-github: #24292e;
+$color-github-hover: lighten($color-github, 5%);
+
+$color-success: #000;
diff --git a/src/styles/1 - settings/_sizes.scss b/src/styles/1 - settings/_sizes.scss
new file mode 100644
index 0000000..fa10419
--- /dev/null
+++ b/src/styles/1 - settings/_sizes.scss
@@ -0,0 +1,19 @@
+//
+// SIZES ---------
+//
+
+$nav-height: 6rem;
+
+$xs-size: 1rem;
+$s-size: 1.2rem;
+$m-size: 1.6rem;
+$l-size: 3.2rem;
+$xl-size: 4.8rem;
+$xxl-size: 5.6rem;
+
+$top: 10rem;
+$top-mobile: 8.5rem;
+$bottom: 15rem;
+$line-height: 2.4rem;
+
+$pad-desktop: 10rem;
\ No newline at end of file
diff --git a/src/styles/1 - settings/_typography.scss b/src/styles/1 - settings/_typography.scss
new file mode 100644
index 0000000..9d878a4
--- /dev/null
+++ b/src/styles/1 - settings/_typography.scss
@@ -0,0 +1,9 @@
+//
+// TYPOGRAPHY ---------
+//
+
+$baseFontSize : 1.6rem;
+$font-small: 1.2rem;
+$font-medium: 1.5rem;
+$font-large: 2rem;
+$font-xlarge: 4rem;
diff --git a/src/styles/1 - settings/_zindex.scss b/src/styles/1 - settings/_zindex.scss
new file mode 100644
index 0000000..19e5e04
--- /dev/null
+++ b/src/styles/1 - settings/_zindex.scss
@@ -0,0 +1,9 @@
+$z-index: (
+ toast: 100,
+ modal: 80,
+ basket: 60,
+ navigation: 55,
+ filter: 40,
+ search: 30,
+ content: 10,
+);
\ No newline at end of file
diff --git a/src/styles/2 - tools/_functions.scss b/src/styles/2 - tools/_functions.scss
new file mode 100644
index 0000000..7b42753
--- /dev/null
+++ b/src/styles/2 - tools/_functions.scss
@@ -0,0 +1,15 @@
+//
+// FUNCTIONS ---------
+//
+
+@function rem($pixels, $context: $baseFontSize) {
+ @if (unitless($pixels)) {
+ $pixels: $pixels * 1px;
+ }
+
+ @if (unitless($context)) {
+ $context: $context * 1px;
+ }
+
+ @return $pixels / $context * 1rem;
+}
diff --git a/src/styles/2 - tools/_mixins.scss b/src/styles/2 - tools/_mixins.scss
new file mode 100644
index 0000000..eacd2fd
--- /dev/null
+++ b/src/styles/2 - tools/_mixins.scss
@@ -0,0 +1,34 @@
+@mixin bezier-transition($prop: all, $speed: .5s, $func: cubic-bezier(.77,0,.175,1) ) {
+ // -webkit-transition: all $speed cubic-bezier(.4, 0, .2, 1);
+ -webkit-transition: $prop $speed $func;
+ -moz-transition: $prop $speed $func;
+ -o-transition: $prop $speed $func;
+ -ms-transition: $prop $speed $func;
+ transition: $prop $speed $func;
+}
+
+
+@mixin l-desktop {
+ @media (min-width: $l-desktop) {
+ @content;
+ }
+}
+
+@mixin desktop {
+ @media (min-width: $desktop) and (max-width: $l-desktop) {
+ @content;
+ }
+}
+
+
+@mixin tablet {
+ @media (max-width: $tablet) {
+ @content;
+ }
+}
+
+@mixin mobile {
+ @media (max-width: $mobile) {
+ @content;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_base.scss b/src/styles/4 - elements/_base.scss
new file mode 100644
index 0000000..5b73b92
--- /dev/null
+++ b/src/styles/4 - elements/_base.scss
@@ -0,0 +1,105 @@
+//
+// BASE STYLING ---------
+//
+* {
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 62.5%;
+}
+
+body {
+ min-height: 100vh;
+ font-family: 'Tajawal', Helvetica, Arial, sans-serif;
+ font-size: $baseFontSize;
+ background: $background-color;
+ font-weight: bold;
+ overflow-x: hidden;
+}
+
+button:hover {
+ cursor: pointer;
+}
+
+button:focus {
+ outline: none;
+}
+
+p {
+ color: $paragraph-color;
+ // font-weight: 300;
+ line-height: $line-height;
+}
+
+strong {
+ font-weight: bold;
+}
+
+span {
+ color: $paragraph-color;
+ font-size: $font-small;
+ position: relative;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: 'Tajawal', Helvetica, Arial, sans-serif;
+ color: $heading-color;
+}
+
+// body::-webkit-scrollbar {
+// width: 8px;
+// }
+
+// body::-webkit-scrollbar-track {
+// -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,0.2);
+// }
+
+// body::-webkit-scrollbar-thumb {
+// background-color: $heading-color;
+// outline: 1px solid lighten($heading-color, 10%);
+// }
+
+#app {
+ width: 100%;
+}
+
+.content {
+ width: 100%;
+ min-height: 100vh;
+ position: relative;
+ padding: $top $pad-desktop;
+ display: flex;
+ animation: fadeIn .5s ease;
+
+ @media (max-width: $mobile) {
+ padding: 5rem $m-size;
+ flex-direction: column;
+ }
+}
+
+.content-admin {
+ width: 100%;
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+ position: relative;
+ padding-top: $xxl-size;
+ overflow: hidden;
+ display: flex;
+}
+
+.content-admin-wrapper {
+ overflow-y: scroll;
+ flex-grow: 1;
+ padding: $m-size 0;
+ padding-top: 0;
+ animation: fadeIn .5s ease;
+ position: relative;
+}
diff --git a/src/styles/4 - elements/_button.scss b/src/styles/4 - elements/_button.scss
new file mode 100644
index 0000000..7c483b1
--- /dev/null
+++ b/src/styles/4 - elements/_button.scss
@@ -0,0 +1,105 @@
+.button {
+ background: $button-color;
+ padding: $m-size;
+ border: 1px solid $button-color;
+ border-radius: 10px;
+ color: $white;
+ font-weight: bold;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ @include bezier-transition(all, .3s, ease);
+
+ &:hover {
+ cursor: pointer;
+ background: $button-hover;
+ border: 1px solid $button-hover;
+ text-decoration: none;
+ color: black;
+ background: linear-gradient(45deg, rgb(253, 112, 112), rgb(252, 252, 143), rgb(130, 130, 252), rgb(250, 126, 126));
+ }
+}
+
+.button:disabled {
+ opacity: .5;
+
+ &:hover {
+ cursor: not-allowed;
+ // background: none;
+ }
+}
+
+.button-link {
+ @extend .button;
+ background: none;
+ color: $black;
+ border: none;
+
+ &:hover {
+ background: none;
+ border: none;
+ }
+}
+
+.button-muted {
+ @extend .button;
+ background: $background-color-01;
+ color: lighten($paragraph-color, 20%);
+ border: 1px solid $border-color;
+
+ &:hover {
+ background: $background-color;
+ border: 1px solid $border-color-focus;
+ }
+}
+
+.button-block {
+ display: block;
+ width: 100%;
+}
+
+.button-border {
+ background: transparent;
+ border: 1px solid $button-color;
+ color: $button-color;
+
+ &:hover {
+ background: $border-color;
+ border: 1px solid $button-color;
+ }
+}
+
+.button-danger {
+ background: red;
+ color: #fff;
+
+ &:hover {
+ background: darken(red, 10%);
+ }
+}
+
+.button-border-gray {
+ border: 1px solid $border-color;
+ color: $paragraph-color;
+
+ &:hover {
+ border: 1px solid $border-color;
+ background: $border-color;
+ }
+}
+
+.button-small {
+ font-size: $font-small;
+ padding: $s-size $m-size;
+}
+.button-icon {
+ display: flex;
+ text-align: center;
+ align-items: center;
+ text-align: center;
+
+ & * {
+ font-size: inherit;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_input.scss b/src/styles/4 - elements/_input.scss
new file mode 100644
index 0000000..6baa528
--- /dev/null
+++ b/src/styles/4 - elements/_input.scss
@@ -0,0 +1,164 @@
+input {
+ border: 1px solid $border-color;
+ background: transparent;
+ font-size: $font-small;
+ padding: $s-size $m-size;
+ font-weight: bold;
+ box-shadow: 0;
+ outline: 0;
+
+ &:focus {
+ outline: none;
+ border: 1px solid $border-color-focus !important;
+ }
+}
+
+input::-webkit-input-placeholder {
+ font-weight: bold;
+ opacity: .7;
+}
+
+input:read-only:not(.price-range-input) {
+ opacity: .5;
+
+ &:hover {
+ cursor: default;
+ }
+}
+
+input[type=tel] {
+ background: transparent;
+ border-radius: 0;
+ padding-left: 48px !important;
+}
+
+input[type=number] {
+ -moz-appearance: textfield;
+}
+input[type=number]::-webkit-outer-spin-button,
+input[type=number]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+}
+input[type=number]:invalid,
+input[type=number]:out-of-range {
+ border: 2px solid #ff6347;
+}
+
+input[type=date] {
+ -moz-appearance: textfield;
+ -webkit-appearance: textfield;
+}
+
+input[type=checkbox],
+input[type=radio] {
+ padding: 0;
+ height: initial;
+ width: initial;
+ margin-bottom: 0;
+ display: none;
+ cursor: pointer;
+}
+
+input[type=checkbox] + label,
+input[type=radio] + label {
+ font-size: $font-medium;
+ background: none;
+ border: none;
+ padding: $s-size 0;
+ display: flex;
+ align-items: center;
+}
+
+
+input[type=checkbox] + label:before,
+input[type=radio] + label:before {
+ content:'';
+ -webkit-appearance: none;
+ background-color: $white;
+ border: 2px solid $border-color-focus;
+ // box-shadow: 0 0 0 $white, inset 0px -15px 10px -12px rgba(0, 0, 0, 0.05);
+ padding: 10px;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+ cursor: pointer;
+ margin-right: 5px;
+ border-radius: 50%;
+ transition: all .3s ease;
+}
+
+input[type=checkbox] + label:after,
+input[type=radio] + label:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 10px;
+ bottom: 0;
+ margin: auto;
+ width: 3px;
+ height: 10px;
+ border: solid $border-color;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ transition: all .3s ease;
+}
+
+input[type=checkbox]:hover + label:before,
+input[type=radio]:hover + label:before {
+ border: 2px solid darken($border-color-focus, 5%);
+}
+
+input[type=checkbox]:checked + label:before,
+input[type=radio]:checked + label:before {
+ border: 2px solid $black;
+ background: $black;
+}
+
+input[type=checkbox]:checked + label:after,
+input[type=radio]:checked + label:after {
+ border: solid $white;
+ border-width: 0 2px 2px 0;
+}
+
+input[type=color] {
+ padding: 0;
+}
+
+
+// override react-phone-input-2
+.react-tel-input input[type=tel] {
+ @extend input;
+ width: 100% !important;
+ font-family: 'Tajawal' !important;
+ // margin-bottom: 1.2rem !important;
+ font-size: $font-medium !important;
+ border: 1px solid $border-color-focus !important;
+}
+
+.react-tel-input input[type=tel].input-error {
+ border: 1px solid $red !important;
+}
+
+.react-tel-input input[type=tel].input-error ~ .flag-dropdown {
+ border: 1px solid $red !important;
+}
+
+.input-message {
+ display: block;
+ padding: $s-size;
+ color: $red;
+ font-weight: bold;
+}
+
+.input-form {
+ padding: 10px $m-size !important;
+ border: 1px solid $border-color-focus !important;
+ font-size: $font-medium !important;
+ // margin-bottom: $s-size;
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_label.scss b/src/styles/4 - elements/_label.scss
new file mode 100644
index 0000000..cdb4352
--- /dev/null
+++ b/src/styles/4 - elements/_label.scss
@@ -0,0 +1,30 @@
+label {
+ font-size: $font-small;
+ font-weight: bold;
+ background: $border-color;
+ border: 1px solid $border-color-focus;
+ padding: $s-size $m-size;
+ display: inline-block;
+ position: relative;
+
+ &:hover {
+ cursor: pointer;
+ background: $border-color-focus;
+ }
+}
+
+.label-input {
+ border: none;
+ background: none;
+ padding: 1rem 1.2rem;
+ color: #696868;
+ border: none;
+
+ &:hover {
+ background: none;
+ }
+}
+
+.label-error {
+ color: rgb(235, 43, 43) !important;
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_link.scss b/src/styles/4 - elements/_link.scss
new file mode 100644
index 0000000..9f91d5d
--- /dev/null
+++ b/src/styles/4 - elements/_link.scss
@@ -0,0 +1,8 @@
+a {
+ text-decoration: none;
+ color: $button-color;
+
+ // &:hover {
+ // text-decoration: underline;
+ // }
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_select.scss b/src/styles/4 - elements/_select.scss
new file mode 100644
index 0000000..6b17a17
--- /dev/null
+++ b/src/styles/4 - elements/_select.scss
@@ -0,0 +1,46 @@
+select {
+ font-size: $font-small;
+ color: $paragraph-color;
+ padding: 7px $m-size;
+ background: transparent;
+ font-weight: bold;
+ border: 1px solid $border-color;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, $border-color 50%),
+ linear-gradient(135deg, $border-color 50%, transparent 50%),
+ linear-gradient(to right, $border-color, $border-color);
+ background-position:
+ calc(100% - 20px) calc(1em + 2px),
+ calc(100% - 15px) calc(1em + 2px),
+ calc(100% - 3.2em) 0.2em;
+ background-size:
+ 5px 5px,
+ 5px 5px,
+ 1px 1.8em;
+ background-repeat: no-repeat;
+
+ &:focus {
+ background: $background-color;
+ border: 1px solid $border-color-focus;
+ background-image:
+ linear-gradient(45deg, $border-color-focus 50%, transparent 50%),
+ linear-gradient(135deg, transparent 50%, $border-color-focus 50%),
+ linear-gradient(to right, $border-color-focus, $border-color-focus);
+ background-position:
+ calc(100% - 15px) 1em,
+ calc(100% - 20px) 1em,
+ calc(100% - 3.2em) 0.2em;
+ background-size:
+ 5px 5px,
+ 5px 5px,
+ 1px 1.5em;
+ background-repeat: no-repeat;
+ outline: none;
+ }
+}
+
+option {
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/src/styles/4 - elements/_textarea.scss b/src/styles/4 - elements/_textarea.scss
new file mode 100644
index 0000000..a245365
--- /dev/null
+++ b/src/styles/4 - elements/_textarea.scss
@@ -0,0 +1,5 @@
+textarea {
+ @extend input;
+ line-height: $line-height;
+ resize: none;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/404/_page-not-found.scss b/src/styles/5 - components/404/_page-not-found.scss
new file mode 100644
index 0000000..51c7703
--- /dev/null
+++ b/src/styles/5 - components/404/_page-not-found.scss
@@ -0,0 +1,10 @@
+.page-not-found {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ padding: $xxl-size;
+ margin: auto;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_auth.scss b/src/styles/5 - components/_auth.scss
new file mode 100644
index 0000000..59eec56
--- /dev/null
+++ b/src/styles/5 - components/_auth.scss
@@ -0,0 +1,230 @@
+.auth {
+ border: 1px solid $border-color-focus;
+ display: flex;
+ justify-content: center;
+ padding: $l-size;
+ padding-top: $m-size;
+
+ @include tablet {
+ flex-direction: column;
+ }
+
+ @include mobile {
+ padding: $m-size;
+ }
+}
+
+.auth-content {
+ width: 80rem;
+ height: auto;
+ margin: auto;
+
+ @include mobile {
+ width: 100%;
+ margin-top: 2rem;
+ }
+
+ .loader {
+ background: $background-color;
+ }
+}
+
+.auth-main {
+ width: 100%;
+
+ @include tablet {
+ text-align: center;
+
+ .input-form {
+ text-align: center;
+ }
+ }
+}
+
+.auth-provider {
+ width: 51rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+
+ @include tablet {
+ width: 100%;
+ }
+
+ & > button {
+ margin-bottom: $s-size;
+ }
+}
+
+.auth-provider-button {
+ width: 100%;
+ font-size: 1.4rem;
+
+ & * {
+ font-size: 1.4rem;
+ }
+
+ .anticon {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: $m-size;
+ margin: auto;
+ }
+}
+
+.auth-button {
+ @include tablet {
+ width: 100%;
+ }
+}
+
+.provider-facebook {
+ @extend .button;
+ background: $color-facebook;
+ color: $white;
+ border: 1px solid $color-facebook;
+
+ &:hover {
+ background: $color-facebook-hover;
+ border: 1px solid $color-facebook;
+ }
+}
+
+.provider-github {
+ @extend .button;
+ background: $color-github;
+ color: $white;
+ border: 1px solid $color-github;
+
+ &:hover {
+ background: $color-github-hover;
+ border: 1px solid $color-github;
+ }
+}
+
+.provider-google {
+ @extend .button;
+ background: $white;
+ color: $gray-01;
+ border: 1px solid $border-color-focus;
+
+ &:hover {
+ background: $border-color;
+ border: 1px solid $border-color;
+ }
+
+ span {
+ flex-basis: 80%;
+ color: $paragraph-color;
+ }
+}
+
+.auth-divider {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ margin: 0 $xl-size;
+
+ h6 {
+ background: $background-color;
+ padding: $s-size;
+ z-index: 1;
+
+ @include mobile {
+ margin: 1rem 0;
+ }
+ }
+
+ &:before,
+ &:after {
+ content: '';
+ position: absolute;
+ height: 30%;
+ width: 1px;
+ background: $border-color;
+
+ @include tablet {
+ width: 50%;
+ height: 1px;
+ }
+ }
+
+ &:before {
+ top: 25%;
+
+ @include mobile {
+ top: 50%;
+ left: 0;
+ }
+ }
+
+ &:after {
+ bottom: 25%;
+
+ @include tablet {
+ right: 0;
+ bottom: 50%;
+ }
+ }
+}
+
+.auth-action {
+ display: flex;
+ justify-content: space-between;
+
+ @include tablet {
+ flex-direction: column;
+
+ a {
+ margin-top: $m-size;
+ margin-bottom: 0;
+ text-align: center;
+ order: 2;
+ }
+
+ button {
+ order: 1;
+ }
+ }
+}
+
+.auth-action-signup {
+ justify-content: flex-end;
+}
+
+.auth-message {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: $s-size;
+ margin: auto;
+ background: $background-color-01;
+ border: 1px solid $border-color-focus;
+ border-top: none;
+}
+
+.auth-info {
+ margin-right: $l-size;
+}
+
+.forgot_password {
+ width: 50%;
+ margin: auto;
+
+ @include mobile {
+ width: 100%;
+ padding: $s-size;
+ }
+}
+
+.auth-success {
+ @extend .toast-success;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_badge.scss b/src/styles/5 - components/_badge.scss
new file mode 100644
index 0000000..54e3a3b
--- /dev/null
+++ b/src/styles/5 - components/_badge.scss
@@ -0,0 +1,19 @@
+.badge {
+ position: relative;
+}
+
+.badge-count {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: $red;
+ position: absolute;
+ top: -12px;
+ right: -15px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $white;
+ font-size: 11px;
+ font-weight: bold;
+}
diff --git a/src/styles/5 - components/_banner.scss b/src/styles/5 - components/_banner.scss
new file mode 100644
index 0000000..da1cccb
--- /dev/null
+++ b/src/styles/5 - components/_banner.scss
@@ -0,0 +1,55 @@
+.banner {
+ width: 100%;
+ height: 40rem;
+ min-height: 600px;
+ border-radius: 10px;
+ max-width: 1200px;
+ margin: 2rem auto;
+
+ background: $background-color-01;
+ display: flex;
+
+ @include mobile {
+ height: auto;
+ flex-direction: column;
+ }
+}
+
+.banner-desc {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ flex-direction: column;
+ padding: 5rem;
+ flex-basis: 50%;
+
+ @include mobile {
+ padding: 5rem 0;
+ }
+
+ h1 {
+ font-size: 4.8rem;
+ margin-bottom: 1rem;
+ width: 80%;
+
+ @include mobile {
+ width: 100%;
+ }
+ }
+}
+
+.banner-img {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ flex-basis: 50%;
+ overflow: hidden;
+ border-radius: 10px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transform: translateX(5rem)
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_basket.scss b/src/styles/5 - components/_basket.scss
new file mode 100644
index 0000000..8967bd2
--- /dev/null
+++ b/src/styles/5 - components/_basket.scss
@@ -0,0 +1,186 @@
+.basket {
+ width: 60rem;
+ height: 100vh;
+ background: $white;
+ position: fixed;
+ top: 0;
+ right: 0;
+ transform: translateX(100%);
+ box-shadow: 0 10px 15px rgba(0, 0, 0, .08);
+ @include bezier-transition(transform);
+ z-index: map-get($z-index, 'basket');
+
+ @include mobile {
+ width: 100%;
+ }
+}
+
+.basket-toggle {
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.basket-list {
+ padding: $m-size;
+ padding-bottom: 100px;
+ overflow-y: scroll;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.basket-item {
+ display: flex;
+ align-items: center;
+ border: 1px solid $border-color;
+ margin-bottom: $s-size;
+ @include bezier-transition();
+ animation: slide-up .5s ease;
+}
+
+.basket-item-wrapper {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ padding: 0 $s-size;
+
+ display: grid;
+ grid-template-columns: 100px 1fr 80px 40px;
+}
+
+.basket-item-specs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+
+ .spec-title {
+ color: #8d8d8d;
+ font-size: 12px;
+ display: block;
+ margin-bottom: 5px;
+ }
+}
+
+.basket-empty {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.basket-empty-msg {
+ color: $gray-10;
+}
+
+.basket-header {
+ display: flex;
+ align-items: center;
+ position: sticky;
+ top: -20px;
+ background: $white;
+ z-index: map-get($z-index, 'basket');
+}
+
+
+.basket-header-title {
+ flex-grow: 1;
+}
+
+.basket-item-img-wrapper {
+ width: 90px;
+ height: 90px;
+ margin-right: $m-size;
+ position: relative;
+}
+
+.basket-item-img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.basket-item-details {
+ flex-grow: 1;
+}
+
+.basket-item-price {
+ margin-right: 2rem;
+}
+
+.basket-item-name {
+ margin: $s-size 0;
+ width: 142px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ position: relative;
+}
+
+.basket-item-remove {
+ align-self: center;
+}
+
+.basket-clear {
+ align-self: center;
+}
+
+.basket-checkout {
+ background: $white;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ padding: $m-size;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ &:before {
+ content: '';
+ position: absolute;
+ top: 0;
+ margin: auto;
+ width: 93%;
+ height: .5px;
+ background: $border-color;
+ }
+}
+
+.basket-checkout-button {
+ font-size: $font-medium;
+ padding: $m-size $l-size;
+ text-transform: uppercase;
+}
+
+.basket-total {
+ // flex-grow: 1;
+}
+
+.basket-total-title {
+ font-size: $font-small;
+ margin: 0;
+}
+
+.basket-total-amount {
+ margin: $s-size 0;
+}
+
+.basket-item-control {
+ width: 30px;
+ height: 90px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 1.5rem;
+}
+
+.basket-control {
+ width: 35px;
+ height: 100%;
+ padding: 5px;
+ font-weight: bold;
+}
+
+.basket-control-count {
+ margin: 5px;
+}
diff --git a/src/styles/5 - components/_circular-progress.scss b/src/styles/5 - components/_circular-progress.scss
new file mode 100644
index 0000000..5ba8364
--- /dev/null
+++ b/src/styles/5 - components/_circular-progress.scss
@@ -0,0 +1,40 @@
+.circular-progress-light {
+ width: 15px;
+ height: 15px;
+ margin-left: $s-size;
+ margin-right: $s-size;
+ border-radius: 50%;
+ border-top: 2px solid $white;
+ border-left: 2px solid $white;
+ animation: spin 2s linear infinite;
+}
+
+.circular-progress-dark {
+ @extend .circular-progress-light;
+ border-top: 2px solid $black;
+ border-left: 2px solid $black;
+}
+
+.progress-loading {
+ width: 100%;
+ height: inherit;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+}
+
+.loading-wrapper {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ background: rgba(0, 0, 0, .5);
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_color-chooser.scss b/src/styles/5 - components/_color-chooser.scss
new file mode 100644
index 0000000..7f35b8c
--- /dev/null
+++ b/src/styles/5 - components/_color-chooser.scss
@@ -0,0 +1,48 @@
+.color-chooser {
+ width: 100%;
+ display: flex;
+
+}
+
+.color-item {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ margin: 0 10px;
+ position: relative;
+ z-index: 1;
+ transition: transform .2s ease;
+ flex-shrink: 0;
+
+ &:hover {
+ cursor: pointer;
+ border: 2px solid #f1f1f1;
+ }
+}
+
+.color-item-selected,
+.color-item-deletable {
+ border: 2px solid #f1f1f1;
+ transform: scale(1.2);
+
+ &:after {
+ content: 'β';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ margin: auto;
+ color: #fff;
+ }
+}
+
+.color-item-deletable {
+ &:after {
+ content: 'β';
+ display: none;
+ }
+
+ &:hover:after {
+ display: block;
+ }
+}
diff --git a/src/styles/5 - components/_filter.scss b/src/styles/5 - components/_filter.scss
new file mode 100644
index 0000000..71a25dc
--- /dev/null
+++ b/src/styles/5 - components/_filter.scss
@@ -0,0 +1,68 @@
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: map-get($z-index, 'filter');
+}
+
+.filters-field {
+ width: 100%;
+ margin-bottom: $m-size;
+ padding-bottom: $m-size;
+ border-bottom: 1px solid $border-color;
+
+ &:nth-child(1),
+ &:nth-child(2) {
+ flex-basis: 50%;
+ }
+
+ @include mobile {
+ flex-basis: 100% !important;
+ }
+}
+
+.filters-brand {
+ width: 100%;
+}
+
+.filters-action {
+ display: flex;
+ width: 100%;
+}
+
+.filters-button {
+ flex-grow: 1;
+}
+
+.filters-toggle {
+ position: relative;
+}
+
+.filters-toggle-sub {
+ width: 400px;
+ height: 360px;
+ background: $white;
+ position: relative;
+ padding: $m-size;
+
+ @include mobile {
+ width: 100%;
+ }
+}
+
+.is-open-filters .filters-toggle-sub {
+ display: block;
+}
+
+.filters-toggle-caret {
+ transform: rotate(-90deg);
+}
+
+.filters-wrapper {
+ display: flex;
+ align-items: center;
+}
+
+// .is-open-filters .filters-toggle-caret {
+// transform: rotate(180deg);
+// }
diff --git a/src/styles/5 - components/_footer.scss b/src/styles/5 - components/_footer.scss
new file mode 100644
index 0000000..2b7f475
--- /dev/null
+++ b/src/styles/5 - components/_footer.scss
@@ -0,0 +1,69 @@
+.footer {
+ position: relative;
+ padding: 0 $xxl-size;
+ margin-top: $xl-size;
+ background: $background-color-01;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @include mobile {
+ padding: 0;
+ padding-top: 2.5rem;
+ flex-direction: column;
+ text-align: center;
+ }
+
+ a {
+ text-decoration: underline;
+ }
+}
+
+.footer-logo {
+ width: 15rem;
+ height: 6rem;
+ object-fit: contain;
+}
+
+.footer-col-1 {
+ flex-basis: 40%;
+
+ @include mobile {
+ flex-basis: none;
+ order: 1;
+ text-align: center;
+ }
+}
+
+.footer-col-2 {
+ padding: 3rem 0;
+ text-align: center;
+ flex-basis: 20%;
+
+ @include mobile {
+ flex-basis: none;
+ order: 3;
+ padding-bottom: 0;
+ margin-bottom: 0;
+ }
+}
+
+.footer-col-3 {
+ flex-basis: 40%;
+ text-align: right;
+
+ @include mobile {
+ flex-basis: none;
+ order: 2;
+ text-align: center;
+ }
+}
+
+@include mobile {
+ .footer-col-1,
+ .footer-col-2,
+ .footer-col-3 {
+ width: 100%;
+ margin: $s-size 0;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_home.scss b/src/styles/5 - components/_home.scss
new file mode 100644
index 0000000..e360589
--- /dev/null
+++ b/src/styles/5 - components/_home.scss
@@ -0,0 +1,12 @@
+.home {
+ width: 100%;
+}
+
+.home-featured {
+ margin: 5rem;
+ margin-top: 10rem;
+}
+
+.featured {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_icons.scss b/src/styles/5 - components/_icons.scss
new file mode 100644
index 0000000..e2c9c1c
--- /dev/null
+++ b/src/styles/5 - components/_icons.scss
@@ -0,0 +1,6 @@
+.anticon {
+ font-size: 1.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_modal.scss b/src/styles/5 - components/_modal.scss
new file mode 100644
index 0000000..2de8bed
--- /dev/null
+++ b/src/styles/5 - components/_modal.scss
@@ -0,0 +1,37 @@
+.ReactModal__Overlay {
+ z-index: map-get($z-index, 'modal');
+}
+
+.ReactModal__Body--open {
+ overflow: hidden;
+}
+
+.ReactModal__Content--before-close {
+ transform: translate(-50%, -50%) scale(0) !important;
+}
+
+.modal-close-button {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ font-size: 2rem;
+ padding: 0;
+ border: none;
+ background: none;
+
+ i {
+ color: lighten($off-black, 10%);
+ }
+
+ &:hover {
+ i {
+ color: $off-black;
+ }
+ }
+}
+
+.ReactModal__Content {
+ @include mobile {
+ width: 90%;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_navigation.scss b/src/styles/5 - components/_navigation.scss
new file mode 100644
index 0000000..e304b04
--- /dev/null
+++ b/src/styles/5 - components/_navigation.scss
@@ -0,0 +1,116 @@
+.navigation {
+ width: 100%;
+ height: 10rem;
+ background: $background-color;
+ display: flex;
+ align-items: center;
+ padding: .5rem 5rem;
+ padding-top: 3rem;
+ position: absolute;
+ top: 0;
+ transform: translateY(0);
+ z-index: map-get($z-index, navigation);
+ box-shadow: none;
+ @include bezier-transition(transform, .3s, ease);
+
+ @include tablet {
+ padding: .5rem 2rem;
+ }
+
+ @include mobile {
+ padding: 0 $s-size;
+ position: fixed;
+ }
+
+ .logo {
+ height: inherit;
+ margin-right: 2rem;
+ }
+
+ .logo img {
+ width: 15rem;
+ height: inherit;
+ object-fit: contain;
+ }
+
+ .logo a {
+ display: block;
+ height: 100%;
+ }
+
+ .searchbar {
+ width: 300px;
+ }
+}
+
+.navigation-admin {
+ height: 6rem;
+ background: $white;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .05);
+ padding: .5rem $l-size;
+ display: flex;
+ justify-content: space-between;
+}
+
+.navigation-menu {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0;
+ margin: 0;
+ text-align: right;
+}
+
+.navigation-menu-main {
+ padding-left: 0;
+ margin-right: 2rem;
+ flex-grow: 0.5;
+
+ li {
+ display: inline-block;
+
+
+ a {
+ padding: 10px 15px;
+ font-size: 1.4rem;
+ opacity: .5;
+
+ &:hover {
+ background: $background-color-01;
+ }
+ }
+ }
+}
+
+.navigation-menu-item {
+ display: inline-block;
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+}
+
+.navigation-action {
+ display: flex;
+ align-items: center;
+ margin-left: $xxl-size;
+}
+
+.navigation-menu-link {
+ color: $heading-color;
+ padding: $xs-size $m-size;
+ text-decoration: none;
+ font-size: $font-small;
+ text-transform: uppercase;
+ font-weight: bold;
+ position: relative;
+
+ &:hover {
+ text-decoration: none;
+ background: $background-color-01;
+ }
+}
+
+.navigation-menu-active {
+ font-weight: bold;
+ opacity: 1 !important;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_pill.scss b/src/styles/5 - components/_pill.scss
new file mode 100644
index 0000000..562d4f5
--- /dev/null
+++ b/src/styles/5 - components/_pill.scss
@@ -0,0 +1,46 @@
+.pill {
+ background: $border-color;
+ padding: 0.8rem $m-size;
+ border-radius: $m-size;
+ display: inline-block;
+ margin: 10px 5px;
+ position: relative;
+ text-align: center;
+
+ &:hover {
+ background: $border-color-focus;
+ }
+}
+
+.pill-title {
+ margin: 0;
+}
+
+.pill-content {
+ max-width: 25rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.pill-remove {
+ position: absolute;
+ right: $s-size;
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.pill-wrapper {
+ text-align: center;
+
+ @include mobile {
+ display: inline-block;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_preloader.scss b/src/styles/5 - components/_preloader.scss
new file mode 100644
index 0000000..eb78ee7
--- /dev/null
+++ b/src/styles/5 - components/_preloader.scss
@@ -0,0 +1,52 @@
+.preloader {
+ width: 100%;
+ height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ background: $background-color;
+ animation: fadeIn .5s ease;
+
+ img {
+ width: 200px;
+ height: 120px;
+ object-fit: contain;
+ }
+}
+
+.loader {
+ width: 100%;
+ height: inherit;
+ background: $background-color-01;
+ position: relative;
+ padding: $m-size;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ animation: fadeIn .3s ease;
+
+ @include mobile {
+ min-height: 80vh;
+ }
+}
+
+.logo-symbol {
+ width: 70px;
+ height: 70px;
+ animation: rotate 1s ease infinite;
+
+ .fill-white {
+ fill: #fff;
+ }
+}
+
+@keyframes rotate {
+ 90% {
+ transform: rotate(360deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_pricerange.scss b/src/styles/5 - components/_pricerange.scss
new file mode 100644
index 0000000..d82f215
--- /dev/null
+++ b/src/styles/5 - components/_pricerange.scss
@@ -0,0 +1,59 @@
+.price-range {
+ width: 100%;
+ position: relative;
+ // box-shadow: inset 0 2px 5px rgba(0, 0, 0, .2);
+}
+
+.price-range-control {
+ position: relative;
+ width: 100%;
+ height: 30px;
+ display: flex;
+ margin-bottom: 3rem;
+}
+
+.price-range-slider {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ z-index: 1;
+
+ &:nth-child(1):hover {
+ background: red;
+ }
+}
+
+.price-range-input {
+ width: 50%;
+ border: none !important;
+ font-size: 4rem !important;
+ padding: 0 !important;
+ flex-grow: 1;
+ text-align: center;
+}
+
+.price-input-error {
+ color: red !important;
+ background: transparentize(red, .9);
+}
+
+.price-range-scale {
+ display: flex;
+ padding: $m-size 0;
+}
+
+.price-range-price {
+ font-size: 12px;
+ font-weight: bold;
+}
+
+.price-range-price:nth-child(1) {
+ flex-grow: 1;
+}
+
+// .range-slider svg,
+// .range-slider input[type=range] {
+// position: absolute;
+// left: 0;
+// bottom: 0;
+// }
\ No newline at end of file
diff --git a/src/styles/5 - components/_product.scss b/src/styles/5 - components/_product.scss
new file mode 100644
index 0000000..0f41201
--- /dev/null
+++ b/src/styles/5 - components/_product.scss
@@ -0,0 +1,562 @@
+.product-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ grid-gap: $s-size;
+ justify-content: center;
+ border-radius: 10px;
+
+ @include mobile {
+ grid-template-columns: repeat(2, 1fr) !important;
+ }
+}
+
+.product-list-header {
+ width: 100%;
+ margin-bottom: $line-height;
+}
+
+.product-list-header-title {
+ text-align: center;
+}
+
+.product-list-header-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ @include mobile {
+ display: none;
+ }
+}
+
+.product-list-search {
+ @include mobile {
+ margin-top: 7rem !important;
+ }
+}
+
+.product-list-title {
+ flex-grow: 1;
+}
+
+.product-list-wrapper {
+ flex-grow: 1;
+ position: relative;
+
+ @include mobile {
+ margin-top: 5rem;
+ }
+}
+
+.product-card {
+ max-width: 180px;
+ height: 230px;
+ border: 1px solid $border-color;
+ text-align: center;
+ position: relative;
+ background: $white;
+ border-radius: 10px; // box-shadow: 0 10px 15px rgba(0, 0, 0, .05);
+ overflow: hidden;
+ // margin: auto;
+ // animation: slide-up .3s ease;
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ @include mobile {
+ height: 200px;
+ }
+}
+
+.product-card:hover .product-card-content {
+ transform: translateY(-10px);
+}
+
+.product-card:hover .product-card-button {
+ bottom: 0;
+}
+
+.product-card:hover .product-card-img-wrapper {
+ height: 8rem;
+ padding: 1rem;
+}
+
+.product-card-check {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ color: $green;
+ z-index: 1;
+}
+
+.product-loading {
+ animation: none;
+}
+
+.product-loading:hover {
+ cursor: default;
+}
+
+.product-loading:hover .product-card-content {
+ transform: none;
+}
+
+.product-loading:hover .product-card-img-wrapper {
+ width: 90%;
+ height: 100px;
+}
+
+.product-card-content {
+ padding: 0;
+ transition: all .4s ease-in-out;
+}
+
+.product-card-img-wrapper {
+ width: 100%;
+ height: 100px;
+ padding: 0 $m-size;
+ margin: auto;
+ position: relative;
+ background: #f6f6f6;
+ transition: all .4s ease-in-out;
+
+ @include mobile {
+ height: 8rem;
+ }
+}
+
+.product-details {
+ padding: $m-size;
+}
+
+.product-card-price {
+ color: $black;
+}
+
+.product-card-name {
+ width: 100%;
+ height: 20px;
+ margin: 0;
+ // white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.product-card-brand {
+ margin-top: 0;
+ font-size: $font-small;
+ color: $gray-20;
+ font-style: italic;
+
+ @include mobile {
+ margin-bottom: 0;
+ }
+}
+
+.product-card-button {
+ // text-transform: uppercase;
+ position: absolute;
+ bottom: -100%;
+ font-size: $font-small;
+ transition: all .4s ease-in-out;
+
+ @include mobile {
+ bottom: 0;
+ display: none;
+ }
+}
+
+.product-card-img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.product-list-empty-filters {
+ margin-top: $xxl-size;
+}
+
+.product-applied-filters {
+ padding: $s-size;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ @include mobile {
+ display: block;
+ }
+}
+
+// PRODUCT MODAL -------------------------------------------------
+.product-view {
+ margin: 0 5rem;
+
+ @include tablet {
+ width: 100%;
+ margin: 0;
+ margin-top: 5rem;
+ }
+}
+
+
+.product-modal {
+ width: 100%;
+ display: flex;
+ background: #fff;
+ border: 1px solid #e1e1e1;
+
+ @include tablet {
+ flex-direction: column;
+ margin: 0;
+ }
+}
+
+.product-modal-image-wrapper {
+ width: 40rem;
+ // height: 40rem;
+ height: inherit;
+ flex-grow: 1;
+ position: relative;
+ background: #f8f8f8;
+
+ @include mobile {
+ width: 100%;
+ height: 20rem;
+ margin: auto;
+ }
+
+ input[type="color"] {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ mix-blend-mode: hue
+ }
+}
+
+.product-modal-image-collection {
+ width: 150px;
+ height: inherit;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+ overflow-y: scroll;
+ position: relative;
+
+ @include tablet {
+ width: 100%;
+ height: auto;
+ order: 2;
+ overflow-x: scroll;
+ overflow-y: unset;
+ flex-direction: row;
+ }
+}
+
+.product-modal-image-collection::-webkit-scrollbar {
+ width: 10px;
+}
+
+.product-modal-image-collection::-webkit-scrollbar-thumb {
+ background-color: #1a1a1a;
+ outline: 1px solid #f8f8f8;
+ // border-radius: .6rem;
+}
+
+.product-modal-image-collection::-webkit-scrollbar-track {
+ -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,.2);
+}
+
+.product-modal-image-collection-wrapper {
+ width: 100%;
+ height: 100px;
+ border: 1px solid #e1e1e1;
+ margin-bottom: 5px;
+ position: relative;
+
+ &:hover {
+ cursor: pointer;
+ background: #fff;
+ }
+ @include tablet {
+ order: 1;
+ width: 100px;
+ }
+}
+
+.product-modal-image-collection-img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.product-modal-image {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.product-modal-details {
+ width: 500px;
+ padding: $l-size;
+
+ @include tablet {
+ width: 100%;
+ padding: $m-size;
+ padding-bottom: $l-size;
+ order: 3;
+ }
+}
+
+.product-modal-action {
+ display: flex;
+ margin-top: $m-size;
+
+ @include mobile {
+ justify-content: center;
+
+ button {
+ width: 100%;
+ }
+ }
+}
+
+// PRODUCT FORM --------------------
+
+.product-form {
+ width: 100%;
+ display: flex;
+ // grid-template-columns: repeat(3, 1fr);
+ // grid-gap: $s-size;
+}
+
+.product-form-field {
+ width: 100%;
+}
+
+.product-form-inputs {
+ width: 75%;
+ display: flex;
+ flex-direction: column;
+}
+
+.product-form-file {
+ width: 25%;
+ margin-left: $l-size;
+ display: flex;
+ flex-direction: column;
+}
+
+.product-form-image-wrapper {
+ width: 100%;
+ height: 200px;
+ position: relative;
+ background: #f4f4f4;
+ border: 1px solid #cacaca;
+}
+
+.product-form-collection {
+ width: 100%;
+ position: relative;
+}
+
+.product-form-delete-image {
+ color: red;
+ border: none;
+ background: none;
+ position: absolute;
+ top: -10px;
+ right: -10px;
+
+ &:hover {
+ color: darken(red, 10%);
+ }
+
+ i {
+ background: #fff;
+ border-radius: 50%;
+ }
+}
+
+.product-form-collection-image {
+ width: 100px;
+ height: 100px;
+ position: relative;
+ display: inline-block;
+ margin: 10px;
+ background: #f4f4f4;
+ border: 1px solid #cacaca;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+}
+
+.product-form-image-preview {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+// PRODUCT SEARCH ------------------
+//
+
+.product-search {
+ width: 100%;
+ position: relative;
+ background: $background-color;
+ z-index: map-get($z-index, 'search');
+}
+
+.product-search-header {
+ width: 100%;
+ height: 6rem;
+ background: $white;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 $m-size;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .02);
+ position: sticky;
+ top: 0;
+ z-index: map-get($z-index, 'navigation');
+}
+
+.product-search-body {
+ padding: 0 $m-size 6rem $m-size;
+ overflow-y: scroll;
+}
+
+.product-search-input {
+ width: 100%;
+ border: none !important;
+ flex-grow: 1;
+ background: $background-color-01;
+ font-size: $font-medium !important;
+
+ &:focus {
+ border: none !important;
+ }
+}
+
+.product-search-wrapper {
+ width: 100%;
+ position: relative;
+ overflow: hidden;
+ margin-left: $m-size;
+}
+
+.product-search-button {
+ opacity: 1 !important;
+ margin-left: 0 !important;
+ margin-right: $s-size;
+}
+
+.product-search-recent-header {
+ width: 100%;
+ display: flex;
+
+ h5 { flex-grow: 1; }
+ h5:last-child { text-align: right; }
+}
+
+.product-search-filter {
+ position: relative;
+ padding: $s-size 0;
+}
+
+.product-search-filter-sub {
+ width: 100%;
+ position: relative;
+
+ .filters-action {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ padding: $m-size;
+ background: $white;
+ z-index: 5;
+ }
+}
+
+.product-search-recent {
+ border-bottom: 1px solid $border-color;
+ margin-bottom: $s-size;
+}
+
+// ---------------------------- FEATURED PRODUCTS ----------------------------
+.display {
+ margin: 5rem;
+ margin-top: 10rem;
+
+ @include mobile {
+ margin: 0;
+ }
+}
+
+.display-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ a {
+ text-decoration: underline;
+ font-size: 1.8rem;
+ }
+}
+
+.product-display-grid {
+ width: 100%;
+ height: auto;
+ display: grid;
+ grid-template-columns: repeat( auto-fit, minmax(30rem, 1fr) );
+ grid-gap: 2rem;
+}
+
+.product-display {
+ width: 100%;
+ max-height: 30rem;
+ border-radius: 15px;
+ border: 1px solid $border-color;
+
+ &:hover {
+ cursor: pointer;
+
+ .product-display-img img {
+ transform: scale(1.1);
+ }
+ }
+}
+
+.product-display-img {
+ width: 100%;
+ height: 20rem;
+ border-radius: 15px 15px 0px 0px;
+ background: #f1f1f1;
+ position: relative;
+ overflow: hidden;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ @include bezier-transition;
+ }
+}
+
+.product-display-details {
+ padding: 2rem;
+
+ h2 {
+ margin: 0
+ }
+
+ p {
+ margin-top: 0;
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_searchbar.scss b/src/styles/5 - components/_searchbar.scss
new file mode 100644
index 0000000..44ca38d
--- /dev/null
+++ b/src/styles/5 - components/_searchbar.scss
@@ -0,0 +1,81 @@
+.searchbar {
+ width: 400px;
+ display: flex;
+ position: relative;
+}
+
+.search-input {
+ padding-left: $xl-size !important;
+}
+
+.searchbar-input {
+ width: 100%;
+ background: $white;
+}
+
+.searchbar-recent {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ background: $white;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .1);
+ display: none;
+}
+
+.searchbar-recent-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 $m-size;
+ position: relative;
+
+ &:after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: 94%;
+ height: 1px;
+ background: $border-color;
+ }
+}
+
+.searchbar-recent-clear:hover {
+ cursor: pointer;
+}
+
+.searchbar-recent-wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 $m-size;
+ padding-right: 0;
+
+ &:hover {
+ background: $border-color;
+ cursor: pointer;
+ }
+}
+
+.searchbar-recent-keyword {
+ flex-grow: 1;
+ padding: $s-size 0;
+}
+
+.searchbar-recent-button {
+ font-weight: bold;
+ padding: $s-size;
+}
+
+.searchbar-icon {
+ position: absolute;
+ left: $m-size;
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ color: #7a7a7a;
+ font-size: 1.6rem;
+}
diff --git a/src/styles/5 - components/_sidebar.scss b/src/styles/5 - components/_sidebar.scss
new file mode 100644
index 0000000..50b44c2
--- /dev/null
+++ b/src/styles/5 - components/_sidebar.scss
@@ -0,0 +1,12 @@
+
+.sidebar {
+ width: 250px;
+ // height: 100vh;
+ position: relative;
+ left: 0;
+ top: 0;
+ padding-top: 5px;
+ // background: $white;
+ // border: 1px solid $border-color;
+ margin-right: $s-size;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_sidenavigation.scss b/src/styles/5 - components/_sidenavigation.scss
new file mode 100644
index 0000000..b759a82
--- /dev/null
+++ b/src/styles/5 - components/_sidenavigation.scss
@@ -0,0 +1,35 @@
+.sidenavigation {
+ width: 250px;
+ height: 100%;
+ background: $black;
+ position: relative;
+ padding-top: 1rem;
+ border-right: 1px solid $border-color;
+}
+
+.sidenavigation-wrapper {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidenavigation-menu {
+ padding: $m-size 0 $m-size $l-size;
+ display: block;
+ color: $white;
+ font-weight: bold;
+ opacity: .8;
+
+ &:hover {
+ background: lighten($black, 5%);
+ text-decoration: none;
+ opacity: 1;
+ }
+}
+
+.sidenavigation-menu-active {
+ background: lighten($black, 7%);
+ opacity: 1;
+ border-right: 4px solid $yellow;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_toast.scss b/src/styles/5 - components/_toast.scss
new file mode 100644
index 0000000..6005422
--- /dev/null
+++ b/src/styles/5 - components/_toast.scss
@@ -0,0 +1,70 @@
+.toast {
+ position: fixed;
+ top: 100px;
+ right: $l-size;
+ background: $white;
+ animation: slideInToast 3s ease;
+ padding: $s-size $m-size;
+ border: 1px solid $border-color-focus;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .1);
+ z-index: map-get($z-index, 'toast');
+
+ @include mobile {
+ width: 80%;
+ right: 0;
+ left: 0;
+ margin: auto;
+ display: flex;
+ justify-content: center;
+ }
+}
+
+.toast-msg {
+ font-weight: bold;
+}
+
+.toast-error {
+ color: $red;
+ border: 1px solid $red;
+ padding: $m-size;
+ background: lighten($red, 40%);
+
+ .toast-msg { color: $red; }
+}
+
+.toast-info {
+ color: $yellow;
+ border: 1px solid $yellow;
+ padding: $m-size;
+ background: lighten($yellow, 45%);
+
+ .toast-msg { color: $yellow; }
+}
+
+.toast-success {
+ color: $green;
+ border: 1px solid $green;
+ padding: $m-size;
+ background: lighten($green, 60%);
+
+ .toast-msg { color: $green; }
+}
+
+@keyframes slideInToast {
+ 0% {
+ opacity: 0;
+ transform: translateY(-120%);
+ }
+ 20% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 90% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateY(-120%);
+ }
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/_tooltip.scss b/src/styles/5 - components/_tooltip.scss
new file mode 100644
index 0000000..8afde8e
--- /dev/null
+++ b/src/styles/5 - components/_tooltip.scss
@@ -0,0 +1,33 @@
+.tooltip {
+ position: relative;
+ display: inline-block;
+ border-bottom: 1px dotted #222;
+ margin-left: 22px;
+ }
+
+ .tooltip .tooltiptext {
+ width: 100px;
+ background-color: #222;
+ color: #fff;
+ opacity: 0.8;
+ text-align: center;
+ border-radius: 6px;
+ padding: 5px 0;
+ position: absolute;
+ z-index: 1;
+ bottom: 150%;
+ left: 50%;
+ margin-left: -60px;
+ }
+
+ .tooltip .tooltiptext::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 5px;
+ border-style: solid;
+ border-color: #222 transparent transparent transparent;
+ }
+
\ No newline at end of file
diff --git a/src/styles/5 - components/admin/_grid.scss b/src/styles/5 - components/admin/_grid.scss
new file mode 100644
index 0000000..8a3a94d
--- /dev/null
+++ b/src/styles/5 - components/admin/_grid.scss
@@ -0,0 +1,54 @@
+.grid {
+ align-items: center;
+ position: relative;
+ // border: 1px solid transparent;
+}
+
+.grid-col {
+ position: relative;
+}
+
+.product-admin-header {
+ display: flex;
+ align-items: center;
+ padding: 0 $l-size;
+ border-bottom: 1px solid $border-color;
+ background: $white;
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 2;
+}
+
+.product-admin-header-title {
+ flex-grow: 1;
+}
+
+.product-admin-filter {
+ position: relative;
+ z-index: map-get($z-index, 'filter');
+ margin-right: $s-size;
+}
+
+.product-admin-items {
+ padding: 0 $l-size;
+}
+
+.product-admin-filter-wrapper {
+ width: 250px;
+ padding: $m-size;
+ position: absolute;
+ top: 0;
+ right: 100%;
+ background: $white;
+ box-shadow: 0 5px 15px rgba(0, 0, 0, .1);
+ display: none;
+}
+
+.product-admin-filter:hover > .product-admin-filter-wrapper {
+ display: block;
+}
+
+.product-form-container {
+ padding: $l-size;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/admin/_product.scss b/src/styles/5 - components/admin/_product.scss
new file mode 100644
index 0000000..35565d6
--- /dev/null
+++ b/src/styles/5 - components/admin/_product.scss
@@ -0,0 +1,100 @@
+.item {
+ position: relative;
+ padding: $s-size;
+ border: 1px solid transparent;
+
+ &:nth-child(even) {
+ background: $white;
+ }
+
+ &:hover {
+ border: 1px solid $border-color-focus;
+ box-shadow: 2px 5px 10px rgba(0, 0, 0, .1);
+ z-index: 1;
+ }
+}
+
+.item-products {
+ padding: 0;
+}
+
+.item:hover > .item-action {
+ display: flex;
+}
+
+// reset styles for loading item
+.item-loading {
+ &:nth-child(even) {
+ background: none;
+ }
+}
+
+.item-loading:hover {
+ border: none;
+ box-shadow: none;
+}
+
+// ----
+
+.item-action {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ padding: $s-size;
+ background: $white;
+ grid-column: span 2;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ display: none;
+}
+
+.item-action-confirm {
+ width: 350px;
+ height: 100%;
+ display: flex;
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: $s-size;
+ display: flex;
+ align-items: center;
+ z-index: 1;
+ display: none;
+
+ h5 {
+ flex-grow: 1;
+ }
+}
+
+.item-active {
+ border: 1px solid $border-color;
+}
+
+.item-active:after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(45deg, rgba(255, 255, 255, .3), $white 90%);
+}
+
+.item-active .item-action-confirm,
+.item-active .item-action {
+ display: flex;
+}
+
+.item-img-wrapper {
+ width: 80px;
+ height: 50px;
+ margin-left: $s-size;
+}
+
+.item-img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
diff --git a/src/styles/5 - components/checkout/_checkout.scss b/src/styles/5 - components/checkout/_checkout.scss
new file mode 100644
index 0000000..f1001a4
--- /dev/null
+++ b/src/styles/5 - components/checkout/_checkout.scss
@@ -0,0 +1,272 @@
+.checkout {
+ width: 100%;
+ animation: slide-up .5s ease;
+
+ @include mobile {
+ margin-top: 2rem;
+ }
+}
+
+.checkout-header {
+ display: flex;
+ justify-content: center;
+ position: sticky;
+ top: $nav-height;
+ background: $background-color;
+ padding: 1rem 0;
+ margin-bottom: 3rem;
+ z-index: 1;
+
+ @include mobile {
+ top: 5rem;
+ }
+}
+
+.checkout-items {
+ // display: flex;
+ // flex-wrap: wrap;
+ // justify-content: center;
+
+ .basket-item {
+ margin: .5rem 1rem;
+
+ @include mobile {
+ margin: 0;
+ }
+ }
+}
+
+.checkout-header-menu {
+ width: 50%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0;
+ margin: auto;
+ position: relative;
+
+ @include mobile {
+ width: 100%;
+ margin: 0;
+ }
+
+ &:before {
+ content: '';
+ position: absolute;
+ top: 15px;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: 85%;
+ height: 3px;
+ background: transparentize($border-color, .5);
+ }
+}
+
+.checkout-header-list {
+ display: flex;
+ justify-content: center;
+}
+
+.checkout-header-item {
+ width: 100px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ z-index: 1;
+}
+
+.checkout-header-step {
+ margin: 0;
+ padding: $m-size;
+ background: $border-color;
+ border-radius: 50%;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $gray-10;
+}
+
+.checkout-header-subtitle {
+ color: $gray-20;
+ margin-top: $s-size;
+ margin-bottom: 0;
+}
+
+.is-active-step .checkout-header-step {
+ background: $black;
+ color: $white;
+}
+
+.is-done-step .checkout-header-step {
+ background: $border-color-focus;
+ color: $white;
+}
+
+.is-active-step .checkout-header-subtitle {
+ color: $black;
+}
+
+.checkout-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @include mobile {
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+
+ button {
+ display: block;
+ width: 100%;
+ }
+ }
+}
+
+.checkout-step-1 {
+ width: 80rem;
+ margin: auto;
+
+ @include tablet {
+ width: 100%;
+ }
+}
+
+.checkout-step-2 {
+ width: 70rem;
+ margin: auto;
+
+ @include tablet {
+ width: 100%;
+ }
+}
+
+.checkout-step-3 {
+ width: 70rem;
+ margin: auto;
+
+ @include tablet {
+ width: 100%;
+ }
+}
+
+.checkout-shipping-wrapper {
+ display: flex;
+ align-items: center;
+}
+
+.checkout-shipping-form {
+ width: 100%;
+}
+
+.checkout-field {
+ width: 100%;
+ margin: 0 $s-size;
+
+ @include mobile {
+ margin: 0
+ }
+}
+
+.checkout-fieldset {
+ display: flex;
+ align-items: center;
+
+ & > div {
+ margin-bottom: $s-size;
+ }
+
+ @include mobile {
+ flex-direction: column;
+ }
+}
+
+.checkout-fieldset-collapse {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ height: 97px;
+ overflow: hidden;
+ transition: all .5s ease;
+ opacity: .6;
+
+ &:hover {
+ opacity: 1;
+ }
+}
+
+.checkout-checkbox-field {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: $m-size;
+ border: 1px solid $border-color;
+ background: #f1f1f1;
+}
+
+.checkout-shipping-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @include mobile {
+ // flex-direction: column;
+ padding: $m-size 0;
+
+ button {
+ width: 50%;
+ display: block;
+ margin: $s-size 0;
+ }
+ }
+}
+
+.checkout-collapse-sub {
+ width: 100%;
+ padding: $m-size;
+ border: 1px solid $border-color;
+ background: $white;
+}
+
+.checkout-cards-accepted {
+ .payment-img {
+ margin: 0 2px;
+ }
+}
+
+.payment-img {
+ width: 48px;
+ height: 32px;
+ background: url('/static/creditcards.png');
+ background-size: 300px 200px;
+ filter: grayscale(1);
+ border-radius: 2px;
+}
+
+.payment-img-paypal {
+ background-position: 103px 115px;
+}
+
+.payment-img-visa {
+ background-position: -55px -15px;
+}
+
+.payment-img-mastercard {
+ background-position: 103px -15px;
+}
+
+.payment-img-express {
+ background-position: -126px -15px;
+}
+
+.payment-img-maestro {
+ background-position: -55px 47px;
+}
+
+.payment-img-discover {
+ background-position: 175px 115px;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/mobile/_bottom-navigation.scss b/src/styles/5 - components/mobile/_bottom-navigation.scss
new file mode 100644
index 0000000..d6fbab0
--- /dev/null
+++ b/src/styles/5 - components/mobile/_bottom-navigation.scss
@@ -0,0 +1,54 @@
+.bottom-navigation {
+ width: 100%;
+ position: fixed;
+ bottom: 0;
+ background: $black;
+ height: 80px;
+}
+
+.bottom-navigation-menu {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0 $m-size;
+ margin: 0;
+}
+
+.bottom-navigation-item {
+ list-style: none;
+ padding: $s-size;
+ height: 100%;
+ color: $white;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ flex-grow: 1;
+
+ &:hover {
+ background: $button-color;
+ }
+}
+
+.bottom-navigation-link {
+ color: $white;
+ text-decoration: none;
+}
+
+.bottom-navigation-icon {
+ width: 35px;
+ height: 35px;
+ overflow: hidden;
+ margin-bottom: 5px;
+}
+
+.bottom-navigation-profile {
+ border-radius: 50%;
+ border: 1px solid $white;
+}
+
+.bottom-navigation-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/mobile/_mobile-navigation.scss b/src/styles/5 - components/mobile/_mobile-navigation.scss
new file mode 100644
index 0000000..985842e
--- /dev/null
+++ b/src/styles/5 - components/mobile/_mobile-navigation.scss
@@ -0,0 +1,74 @@
+.mobile-navigation {
+ width: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ // background: $background-color;
+ background: #fff;
+ z-index: map-get($z-index, 'navigation');
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .1);
+}
+
+.mobile-navigation-main {
+ width: 100%;
+ height: 50px;
+ padding: 0 $s-size;
+ display: flex;
+ align-items: center;
+ position: relative;
+ z-index: 1;
+}
+
+.mobile-navigation-sec {
+ display: flex;
+ align-items: center;
+ padding: 5px $s-size;
+}
+
+.mobile-navigation-menu {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+
+ .user-nav {
+ height: 100%;
+ margin: 0;
+ }
+
+ .user-nav h5 {
+ display: none;
+ }
+}
+
+.mobile-navigation-item {
+ // width: 50%;
+ height: 100%;
+ list-style: none;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ // border: 1px solid $border-color;
+}
+
+.mobile-navigation-logo {
+ flex-grow: 1;
+ padding-left: .5rem;
+ margin-right: $xl-size;
+}
+
+.mobile-navigation-search {
+ width: 50%;
+ height: 80%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-bottom: 1px solid $paragraph-color;
+}
+
+.mobile-navigation-search-title {
+ margin: .6rem;
+ font-weight: normal;
+}
diff --git a/src/styles/5 - components/profile/_editprofile.scss b/src/styles/5 - components/profile/_editprofile.scss
new file mode 100644
index 0000000..c35e3ee
--- /dev/null
+++ b/src/styles/5 - components/profile/_editprofile.scss
@@ -0,0 +1,76 @@
+.edit-user {
+ width: 600px;
+ height: auto;
+ padding: $m-size;
+ margin: auto;
+ display: flex;
+ flex-direction: column;
+
+ @include mobile {
+ width: 100%;
+ padding: 0;
+ }
+
+ .user-profile-banner-wrapper,
+ .user-profile-avatar-wrapper {
+ overflow: visible;
+ }
+}
+
+.edit-user-details {
+ width: 60%;
+}
+
+.edit-user-images {
+ width: 40%;
+}
+
+// .edit-wrapper {
+// position: absolute;
+// left: 0;
+// right: 0;
+// top: 0;
+// bottom: 0;
+// width: 3rem;
+// height: 3rem;
+// border-radius: 50%;
+// background: $black;
+// color: $white;
+// }
+
+.edit-button {
+ padding: $s-size;
+ position: absolute;
+ bottom: -10px;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ padding: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border: none !important;
+ background: $black !important;
+ color: $white;
+}
+
+.edit-avatar-button {
+ right: 0;
+}
+
+.edit-banner-button {
+ right: 2rem;
+}
+
+.edit-user-action {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @include mobile {
+ button {
+ width: 50%;
+ }
+ }
+}
+
diff --git a/src/styles/5 - components/profile/_user-nav.scss b/src/styles/5 - components/profile/_user-nav.scss
new file mode 100644
index 0000000..277003f
--- /dev/null
+++ b/src/styles/5 - components/profile/_user-nav.scss
@@ -0,0 +1,87 @@
+.user-nav {
+ margin-left: $m-size;
+ display: flex;
+ align-items: center;
+ position: relative;
+ padding: 0 $s-size;
+ border-bottom: 2px solid transparent;
+ display: flex;
+ justify-content: center;
+
+ @include mobile {
+ width: 100%;
+ }
+
+ &:hover {
+ cursor: pointer;
+ // border-bottom: 2px solid $black;
+ }
+}
+
+.user-nav-img-wrapper {
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ overflow: hidden;
+ margin-left: 10px;
+ position: relative;
+ background: $border-color-focus;
+
+ &:after {
+ content: attr('data-alt');
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ margin: auto;
+ color: $white;
+ }
+}
+
+.user-nav-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.user-nav-sub {
+ width: 150px;
+ height: auto;
+ background: $white;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ box-shadow: 0 5px 12px rgba(0, 0, 0, .1);
+ display: none;
+}
+
+.user-nav-sub-link {
+ font-size: $font-small;
+ font-weight: bold;
+ display: flex;
+ justify-content: space-between;
+ padding: $s-size $m-size;
+ text-align: left;
+
+ &:hover {
+ background: $background-color;
+ text-decoration: none;
+ }
+
+ &:first-child {
+ border-bottom: .5px solid $background-color;
+ }
+}
+
+.user-sub-open {
+ border-bottom: 2px solid $black;
+}
+
+.user-sub-open .user-caret {
+ transform: rotate(180deg);
+}
+
+.user-sub-open > .user-nav-sub {
+ display: block;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/profile/_user-profile.scss b/src/styles/5 - components/profile/_user-profile.scss
new file mode 100644
index 0000000..f13d044
--- /dev/null
+++ b/src/styles/5 - components/profile/_user-profile.scss
@@ -0,0 +1,90 @@
+.user-profile {
+ display: flex;
+ margin: auto;
+
+ @include mobile {
+ width: 100%;
+ }
+}
+
+.user-profile-block {
+ // width: 600px;
+ width: 100%;
+ height: auto;
+ // padding: $m-size;
+
+ // @include mobile {
+ // padding: 0;
+ // }
+}
+
+.user-profile-banner-wrapper {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ background: lighten($border-color, 3%);
+ overflow: hidden;
+ // display: flex;
+ // justify-content: center;
+ // align-items: center;
+}
+
+.user-profile-banner {
+ width: 100%;
+ height: 150px;
+ position: relative;
+
+ @include mobile {
+ height: 100px;
+ }
+}
+
+.user-profile-banner-img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ left: 0;
+ top: 0;
+}
+
+.user-profile-avatar-wrapper {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ border: 3px solid $white;
+ position: absolute;
+ left: $m-size;
+ bottom: 30%;
+ position: relative;
+ background: $border-color;
+ overflow: hidden;
+ // display: flex;
+ // justify-content: center;
+ // align-items: center;
+}
+
+.user-profile-img {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.user-profile-edit {
+ position: absolute;
+ right: $s-size;
+ bottom: -12%;
+}
+
+.user-profile-details {
+ padding: 9.5rem $m-size $l-size;
+
+ & > div {
+ margin-bottom: $s-size;
+ }
+}
+
+.user-profile-name {
+ text-transform: capitalize;
+}
\ No newline at end of file
diff --git a/src/styles/5 - components/profile/_user-tab.scss b/src/styles/5 - components/profile/_user-tab.scss
new file mode 100644
index 0000000..0d0c6c9
--- /dev/null
+++ b/src/styles/5 - components/profile/_user-tab.scss
@@ -0,0 +1,66 @@
+.user-tab {
+ width: 700px;
+ height: 100%;
+ margin: 0 auto;
+ margin-top: 3rem;
+ background: $white;
+ border: 1px solid $border-color;
+
+ @media (max-width: $mobile) {
+ width: 100%;
+ margin-top: 6rem;
+ }
+}
+
+.user-tab-content {
+ padding: $m-size;
+ height: 100%;
+
+ @include mobile {
+ padding: 0;
+ }
+}
+
+.user-tab-nav {
+ background: $background-color-01;
+ border-bottom: 1px solid $border-color;
+ padding: 0 $l-size;
+
+ @media (max-width: $mobile) {
+ padding: 0;
+ }
+}
+
+.user-tab-menu {
+ padding: 0;
+ margin: 0;
+ position: relative;
+ bottom: -1px;
+}
+
+.user-tab-item {
+ list-style-type: none;
+ color: $gray-10;
+ padding: $m-size;
+ font-size: $font-medium;
+ border-bottom: 1px solid transparent;
+ display: inline-block;
+ transition: all .3s ease;
+
+ &:hover {
+ cursor: pointer;
+ background: $background-color;
+ }
+}
+
+.user-tab-active {
+ color: $paragraph-color;
+ font-weight: bold;
+ border-bottom: 1px solid $white;
+ background: $white;
+
+ &:hover {
+ cursor: default;
+ background: $white;
+ }
+}
diff --git a/src/styles/6 - utils/_animation.scss b/src/styles/6 - utils/_animation.scss
new file mode 100644
index 0000000..43d28bc
--- /dev/null
+++ b/src/styles/6 - utils/_animation.scss
@@ -0,0 +1,49 @@
+@keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes spin {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes slide-up {
+ 0% {
+ opacity: 0;
+ transform: translateY(50px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+@keyframes scale {
+ 0% {
+ transform: translate(-50%, -50%) scale(0);
+ }
+ 100% {
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+@keyframes fullWidth {
+ 100% { width: 100%; }
+}
+
+@keyframes slide-down {
+ 0% {
+ transform: translateY(-100%);
+ }
+
+ 100% {
+ transform: translateY(0);
+ }
+}
\ No newline at end of file
diff --git a/src/styles/6 - utils/_state.scss b/src/styles/6 - utils/_state.scss
new file mode 100644
index 0000000..488cfe0
--- /dev/null
+++ b/src/styles/6 - utils/_state.scss
@@ -0,0 +1,45 @@
+//
+// STATE ---------
+//
+
+.is-active-link {
+ font-weight: bold;
+ color: green;
+}
+
+.is-basket-open {
+ overflow-y: hidden;
+}
+
+.is-selected-payment {
+ opacity: 1;
+}
+
+.is-basket-open .basket {
+ transform: translateX(0);
+}
+
+.is-img-loading {
+ opacity: 0;
+}
+
+.is-img-loaded {
+ animation: fadeIn .3s ease;
+ opacity: 1;
+}
+
+.is-open-recent-search .searchbar-recent {
+ display: flex;
+ flex-direction: column;
+}
+
+.is-nav-scrolled {
+ position: fixed;
+ animation: slide-down .3s ease 1;
+ animation-fill-mode: forwards;
+ top: 0;
+ height: 6rem;
+ padding-top: .5rem;
+ background: $nav-bg-scrolled;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, .02);
+}
\ No newline at end of file
diff --git a/src/styles/6 - utils/_utils.scss b/src/styles/6 - utils/_utils.scss
new file mode 100644
index 0000000..6263e7d
--- /dev/null
+++ b/src/styles/6 - utils/_utils.scss
@@ -0,0 +1,259 @@
+//
+// UTILS ---------
+//
+
+.w-100 {
+ width: 100%;
+}
+
+.h-100 {
+ height: 100%;
+}
+
+
+.d-block {
+ display: block;
+ width: 100%;
+}
+
+.w-100-mobile {
+ @include mobile {
+ width: 100%;
+ }
+}
+
+.d-flex {
+ display: flex;
+ align-items: center;
+}
+
+.d-flex-center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.d-flex-end {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.d-flex-grow-1 {
+ flex-grow: 1;
+}
+
+.d-inline-flex {
+ display: inline-flex;
+}
+
+.background-dark {
+ background: $black;
+}
+
+.color-light {
+ color: $white;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-link {
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.text-italic {
+ font-style: italic;
+}
+
+.text-overflow-ellipsis {
+ max-width: 120px;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.input-valid {
+ border: 1px solid green;
+}
+
+.input-error {
+ border: 1px solid $red !important;
+
+ &:focus {
+ border: 1px solid $red !important;
+ }
+}
+
+.text-subtle {
+ color: $gray-10;
+}
+
+.padding-0 {
+ padding: 0;
+}
+
+.padding-s {
+ padding: $s-size;
+}
+
+.padding-l {
+ padding: $l-size;
+}
+
+.padding-xl {
+ padding: $xl-size;
+}
+
+.padding-left-s {
+ padding-left: $s-size;
+}
+
+.padding-left-m {
+ padding-left: $m-size;
+}
+
+.padding-left-l {
+ padding-left: $l-size;
+}
+
+.padding-left-xl {
+ padding-left: $xl-size;
+}
+
+.padding-left-xxl {
+ padding-left: $xxl-size;
+}
+
+.padding-right-s {
+ padding-right: $s-size;
+}
+
+.padding-right-m {
+ padding-right: $m-size;
+}
+
+.padding-right-l {
+ padding-right: $l-size;
+}
+
+.padding-right-xl {
+ padding-right: $xl-size;
+}
+
+.padding-right-xxl {
+ padding-right: $xxl-size;
+}
+
+.margin-left-s {
+ margin-left: $s-size;
+}
+
+.margin-left-m {
+ margin-left: $m-size;
+}
+
+.margin-left-l {
+ margin-left: $l-size;
+}
+
+.margin-left-xl {
+ margin-left: $xl-size;
+}
+
+.margin-left-xxl {
+ margin-left: $xxl-size;
+}
+
+.margin-right-s {
+ margin-right: $s-size;
+}
+
+.margin-right-m {
+ margin-right: $m-size;
+}
+
+.margin-right-l {
+ margin-right: $l-size;
+}
+
+.margin-right-xl {
+ margin-right: $xl-size;
+}
+
+.margin-right-xxl {
+ margin-right: $xxl-size;
+}
+
+.margin-top-s {
+ margin-top: $s-size;
+}
+
+.margin-top-0 {
+ margin-top: 0;
+}
+
+.margin-auto {
+ margin: auto;
+}
+
+.margin-0 {
+ margin: 0;
+}
+
+.divider {
+ width: 100%;
+ height: 1px;
+ background: #f1f1f1;
+}
+
+.flex-justify-end {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.grid-count-4 {
+ display: grid;
+ grid-gap: $s-size;
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.grid-count-6 {
+ display: grid;
+ grid-gap: $s-size;
+ grid-template-columns: repeat(6, 1fr);
+}
+
+.position-relative {
+ position: relative;
+}
+
+.text-subtle {
+ color: $gray-10;
+}
+
+.text-italic {
+ font-style: italic;
+}
+
+.text-thin {
+ font-weight: 400;
+}
+
+.my-0 {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.underline {
+ text-decoration: underline;
+}
diff --git a/src/styles/style.scss b/src/styles/style.scss
new file mode 100644
index 0000000..736449c
--- /dev/null
+++ b/src/styles/style.scss
@@ -0,0 +1,87 @@
+// ---------------------------
+// 00 - VENDORS
+
+// ---------------------------
+// 01 - SETTINGS
+
+@import './1 - settings/breakpoints';
+@import './1 - settings/colors';
+@import './1 - settings/sizes';
+@import './1 - settings/typography';
+@import './1 - settings/zindex';
+
+// ---------------------------
+// 02 - TOOLS
+
+@import './2 - tools/functions';
+@import './2 - tools/mixins';
+
+// ---------------------------
+// 03 - GENERIC
+
+// ---------------------------
+// 04 - ELEMENTS
+
+@import './4 - elements/base';
+@import './4 - elements/button';
+@import './4 - elements/input';
+@import './4 - elements/link';
+@import './4 - elements/select';
+@import './4 - elements/textarea';
+@import './4 - elements/label';
+
+
+// ---------------------------
+// 05 - COMPONENTS
+
+@import './5 - components/icons';
+@import './5 - components/navigation';
+@import './5 - components/product';
+@import './5 - components/basket';
+@import './5 - components/sidebar';
+@import './5 - components/searchbar';
+@import './5 - components/badge';
+@import './5 - components/footer';
+@import './5 - components/filter';
+@import './5 - components/pricerange';
+@import './5 - components/modal';
+@import './5 - components/auth';
+@import './5 - components/banner';
+@import './5 - components/toast';
+@import './5 - components/home';
+@import './5 - components/circular-progress';
+@import './5 - components/preloader';
+@import './5 - components/pill';
+@import './5 - components/tooltip';
+@import './5 - components/color-chooser';
+
+// -- Admin components
+@import './5 - components/admin/grid';
+@import './5 - components/admin/product';
+@import './5 - components/sidenavigation';
+// @import './5 - components/admin/user';
+
+// -- Mobile components
+// @import './5 - components/mobile/bottom-navigation';
+@import './5 - components/mobile/mobile-navigation';
+
+// -- Profile components
+@import './5 - components/profile/user-nav';
+@import './5 - components/profile/user-profile';
+@import './5 - components/profile/user-tab';
+@import './5 - components/profile/editprofile';
+
+// -- Checkout components
+@import './5 - components/checkout/checkout';
+
+// -- 404
+@import './5 - components/404/page-not-found';
+
+
+
+// ---------------------------
+// 06 - UTILS
+
+@import './6 - utils/state';
+@import './6 - utils/animation';
+@import './6 - utils/utils';
diff --git a/src/sw-src.js b/src/sw-src.js
new file mode 100644
index 0000000..755d809
--- /dev/null
+++ b/src/sw-src.js
@@ -0,0 +1,37 @@
+import { precacheAndRoute } from 'workbox-precaching';
+import { registerRoute } from 'workbox-routing';
+import { CacheFirst } from 'workbox-strategies';
+import { ExpirationPlugin } from 'workbox-expiration';
+import { cacheNames } from 'workbox-core';
+
+precacheAndRoute(self.__WB_MANIFEST);
+let currentCacheNames = Object.assign({ precacheTemp: cacheNames.precache + "-temp" }, cacheNames);
+
+currentCacheNames.fonts = "googlefonts";
+registerRoute(
+ /https:\/\/fonts.(?:googleapis|gstatic).com\/(.*)/,
+ new CacheFirst({
+ cacheName: currentCacheNames.fonts,
+ plugins: [new ExpirationPlugin({ maxEntries: 30 })]
+ }),
+ "GET"
+);
+
+// clean up old SW caches
+self.addEventListener("activate", function (event) {
+ event.waitUntil(
+ caches.keys().then(function (cacheNames) {
+ let validCacheSet = new Set(Object.values(currentCacheNames));
+ return Promise.all(
+ cacheNames
+ .filter(function (cacheName) {
+ return !validCacheSet.has(cacheName);
+ })
+ .map(function (cacheName) {
+ console.log("deleting cache", cacheName);
+ return caches.delete(cacheName);
+ })
+ );
+ })
+ );
+});
\ No newline at end of file
diff --git a/src/views/account/components/UserAccountTab.jsx b/src/views/account/components/UserAccountTab.jsx
new file mode 100644
index 0000000..5d60f26
--- /dev/null
+++ b/src/views/account/components/UserAccountTab.jsx
@@ -0,0 +1,77 @@
+/* eslint-disable indent */
+import { ImageLoader } from 'components/common';
+import { ACCOUNT_EDIT } from 'constants/routes';
+import { displayDate } from 'helpers/utils';
+import PropType from 'prop-types';
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+
+const UserProfile = (props) => {
+ const profile = useSelector((state) => state.profile);
+
+ return (
+
+
+
+
+
+
+
+
+
+
props.history.push(ACCOUNT_EDIT)}
+ type="button"
+ >
+ Edit Account
+
+
+
+
{profile.fullname}
+ Email
+
+ {profile.email}
+ Address
+
+ {profile.address ? (
+ {profile.address}
+ ) : (
+ Address not set
+ )}
+ Mobile
+
+ {profile.mobile ? (
+ {profile.mobile.value}
+ ) : (
+ Mobile not set
+ )}
+ Date Joined
+
+ {profile.dateJoined ? (
+ {displayDate(profile.dateJoined)}
+ ) : (
+ Not available
+ )}
+
+
+
+ );
+};
+
+UserProfile.propTypes = {
+ history: PropType.shape({
+ push: PropType.func
+ }).isRequired
+};
+
+export default withRouter(UserProfile);
diff --git a/src/views/account/components/UserAvatar.jsx b/src/views/account/components/UserAvatar.jsx
new file mode 100644
index 0000000..4f95dc1
--- /dev/null
+++ b/src/views/account/components/UserAvatar.jsx
@@ -0,0 +1,93 @@
+/* eslint-disable indent */
+import {
+ DownOutlined, LoadingOutlined, LogoutOutlined, UserOutlined
+} from '@ant-design/icons';
+import { ACCOUNT } from 'constants/routes';
+import PropTypes from 'prop-types';
+import React, { useEffect, useRef } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { Link, withRouter } from 'react-router-dom';
+import { signOut } from 'redux/actions/authActions';
+
+const UserNav = () => {
+ const { profile, isAuthenticating } = useSelector((state) => ({
+ profile: state.profile,
+ isAuthenticating: state.app.isAuthenticating
+ }));
+ const userNav = useRef(null);
+ const dispatch = useDispatch();
+
+ const toggleDropdown = (e) => {
+ const closest = e.target.closest('div.user-nav');
+
+ try {
+ if (!closest && userNav.current.classList.contains('user-sub-open')) {
+ userNav.current.classList.remove('user-sub-open');
+ }
+ } catch (err) {
+ console.log(err);
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('click', toggleDropdown);
+
+ return () => document.removeEventListener('click', toggleDropdown);
+ }, []);
+
+ const onClickNav = () => {
+ userNav.current.classList.toggle('user-sub-open');
+ };
+
+ return isAuthenticating ? (
+
+ Signing Out
+
+
+
+ ) : (
+ { }}
+ ref={userNav}
+ role="button"
+ tabIndex={0}
+ >
+
{profile.fullname && profile.fullname.split(' ')[0]}
+
+
+
+
+
+ {profile.role !== 'ADMIN' && (
+
+ View Account
+
+
+ )}
+
dispatch(signOut())}
+ role="presentation"
+ >
+ Sign Out
+
+
+
+
+ );
+};
+
+UserNav.propType = {
+ profile: PropTypes.object.isRequired
+};
+
+export default withRouter(UserNav);
diff --git a/src/views/account/components/UserOrdersTab.jsx b/src/views/account/components/UserOrdersTab.jsx
new file mode 100644
index 0000000..b0a216a
--- /dev/null
+++ b/src/views/account/components/UserOrdersTab.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+// Just add this feature if you want :P
+
+const UserOrdersTab = () => (
+
+
My Orders
+ You don't have any orders
+
+);
+
+export default UserOrdersTab;
diff --git a/src/views/account/components/UserTab.jsx b/src/views/account/components/UserTab.jsx
new file mode 100644
index 0000000..2ec1907
--- /dev/null
+++ b/src/views/account/components/UserTab.jsx
@@ -0,0 +1,43 @@
+import PropType from 'prop-types';
+import React, { useState } from 'react';
+
+const UserTab = (props) => {
+ const { children } = props;
+ const [activeTab, setActiveTab] = useState(children[0].props.index || 0);
+ const onClickTabItem = (index) => setActiveTab(index);
+
+ return (
+
+
+
+ {children.map((child) => (
+ onClickTabItem(child.props.index)}
+ >
+ {child.props.label}
+
+ ))}
+
+
+
+ {children.map((child) => {
+ if (child.props.index !== activeTab) return null;
+
+ return child.props.children;
+ })}
+
+
+ );
+};
+
+UserTab.propTypes = {
+ children: PropType.oneOfType([
+ PropType.arrayOf(PropType.node),
+ PropType.node
+ ]).isRequired
+};
+
+export default UserTab;
diff --git a/src/views/account/components/UserWishListTab.jsx b/src/views/account/components/UserWishListTab.jsx
new file mode 100644
index 0000000..761849d
--- /dev/null
+++ b/src/views/account/components/UserWishListTab.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+
+// Just add this feature if you want :P
+
+const UserWishListTab = () => (
+
+
My Wish List
+ You don't have a wish list
+
+);
+
+export default UserWishListTab;
diff --git a/src/views/account/edit_account/ConfirmModal.jsx b/src/views/account/edit_account/ConfirmModal.jsx
new file mode 100644
index 0000000..2ae8da2
--- /dev/null
+++ b/src/views/account/edit_account/ConfirmModal.jsx
@@ -0,0 +1,68 @@
+import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
+import { Modal } from 'components/common';
+import { useFormikContext } from 'formik';
+import PropType from 'prop-types';
+import React, { useState } from 'react';
+
+const ConfirmModal = ({ onConfirmUpdate, modal }) => {
+ const [password, setPassword] = useState('');
+ const { values } = useFormikContext();
+
+ return (
+
+
+
Confirm Update
+
+ To continue updating profile including your
+ email
+ ,
+
+ please confirm by entering your password
+
+
setPassword(e.target.value)}
+ placeholder="Enter your password"
+ required
+ type="password"
+ value={password}
+ />
+
+
+
+ {
+ onConfirmUpdate(values, password);
+ modal.onCloseModal();
+ }}
+ type="button"
+ >
+
+
+ Confirm
+
+
+
+
+
+
+ );
+};
+
+ConfirmModal.propTypes = {
+ onConfirmUpdate: PropType.func.isRequired,
+ modal: PropType.shape({
+ onCloseModal: PropType.func,
+ isOpenModal: PropType.bool
+ }).isRequired
+};
+
+export default ConfirmModal;
diff --git a/src/views/account/edit_account/EditForm.jsx b/src/views/account/edit_account/EditForm.jsx
new file mode 100644
index 0000000..0bbed2b
--- /dev/null
+++ b/src/views/account/edit_account/EditForm.jsx
@@ -0,0 +1,79 @@
+import { ArrowLeftOutlined, CheckOutlined, LoadingOutlined } from '@ant-design/icons';
+import { CustomInput, CustomMobileInput } from 'components/formik';
+import { ACCOUNT } from 'constants/routes';
+import { Field, useFormikContext } from 'formik';
+import PropType from 'prop-types';
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+
+const EditForm = ({ isLoading, authProvider }) => {
+ const history = useHistory();
+ const { values, submitForm } = useFormikContext();
+
+ return (
+
+
+
+
+
+
+
+
history.push(ACCOUNT)}
+ type="button"
+ >
+
+
+ Back to Profile
+
+
+ {isLoading ? : }
+
+ {isLoading ? 'Updating Profile' : 'Update Profile'}
+
+
+
+ );
+};
+
+EditForm.propTypes = {
+ isLoading: PropType.bool.isRequired,
+ authProvider: PropType.string.isRequired
+};
+
+export default EditForm;
diff --git a/src/views/account/edit_account/index.jsx b/src/views/account/edit_account/index.jsx
new file mode 100644
index 0000000..621dbec
--- /dev/null
+++ b/src/views/account/edit_account/index.jsx
@@ -0,0 +1,184 @@
+import { EditOutlined, LoadingOutlined } from '@ant-design/icons';
+import { Boundary, ImageLoader } from 'components/common';
+import { Formik } from 'formik';
+import {
+ useDocumentTitle, useFileHandler, useModal, useScrollTop
+} from 'hooks';
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { setLoading } from 'redux/actions/miscActions';
+import { updateProfile } from 'redux/actions/profileActions';
+import * as Yup from 'yup';
+import ConfirmModal from './ConfirmModal';
+import EditForm from './EditForm';
+
+const FormSchema = Yup.object().shape({
+ fullname: Yup.string()
+ .min(4, 'Full name should be at least 4 characters.')
+ .max(60, 'Full name should be only be 4 characters long.')
+ .required('Full name is required'),
+ email: Yup.string()
+ .email('Email is not valid.')
+ .required('Email is required.'),
+ address: Yup.string(),
+ mobile: Yup.object()
+ .shape({
+ country: Yup.string(),
+ countryCode: Yup.string(),
+ dialCode: Yup.string(),
+ value: Yup.string()
+ })
+});
+
+const EditProfile = () => {
+ useDocumentTitle('Edit Account | Salinaka');
+ useScrollTop();
+
+ const modal = useModal();
+ const dispatch = useDispatch();
+
+ useEffect(() => () => {
+ dispatch(setLoading(false));
+ }, []);
+
+ const { profile, auth, isLoading } = useSelector((state) => ({
+ profile: state.profile,
+ auth: state.auth,
+ isLoading: state.app.loading
+ }));
+
+ const initFormikValues = {
+ fullname: profile.fullname || '',
+ email: profile.email || '',
+ address: profile.address || '',
+ mobile: profile.mobile || {}
+ };
+
+ const {
+ imageFile,
+ isFileLoading,
+ onFileChange
+ } = useFileHandler({ avatar: {}, banner: {} });
+
+ const update = (form, credentials = {}) => {
+ dispatch(updateProfile({
+ updates: {
+ fullname: form.fullname,
+ email: form.email,
+ address: form.address,
+ mobile: form.mobile,
+ avatar: profile.avatar,
+ banner: profile.banner
+ },
+ files: {
+ bannerFile: imageFile.banner.file,
+ avatarFile: imageFile.avatar.file
+ },
+ credentials
+ }));
+ };
+
+ const onConfirmUpdate = (form, password) => {
+ if (password) {
+ update(form, { email: form.email, password });
+ }
+ };
+
+ const onSubmitUpdate = (form) => {
+ // check if data has changed
+ const fieldsChanged = Object.keys(form).some((key) => profile[key] !== form[key]);
+
+ if (fieldsChanged || (Boolean(imageFile.banner.file || imageFile.avatar.file))) {
+ if (form.email !== profile.email) {
+ modal.onOpenModal();
+ } else {
+ update(form);
+ }
+ }
+ };
+
+ return (
+
+
+
Edit Account Details
+
+ {() => (
+ <>
+
+
+
+ {isFileLoading ? (
+
+
+
+ ) : (
+
+ onFileChange(e, { name: 'banner', type: 'single' })}
+ type="file"
+ />
+
+
+ )}
+
+
+
+ {isFileLoading ? (
+
+
+
+ ) : (
+
+ onFileChange(e, { name: 'avatar', type: 'single' })}
+ type="file"
+ />
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default EditProfile;
diff --git a/src/views/account/user_account/index.jsx b/src/views/account/user_account/index.jsx
new file mode 100644
index 0000000..1130862
--- /dev/null
+++ b/src/views/account/user_account/index.jsx
@@ -0,0 +1,43 @@
+/* eslint-disable react/no-multi-comp */
+import { LoadingOutlined } from '@ant-design/icons';
+import { useDocumentTitle, useScrollTop } from 'hooks';
+import React, { lazy, Suspense } from 'react';
+import UserTab from '../components/UserTab';
+
+const UserAccountTab = lazy(() => import('../components/UserAccountTab'));
+const UserWishListTab = lazy(() => import('../components/UserWishListTab'));
+const UserOrdersTab = lazy(() => import('../components/UserOrdersTab'));
+
+const Loader = () => (
+
+
+
Loading ...
+
+);
+
+const UserAccount = () => {
+ useScrollTop();
+ useDocumentTitle('My Account | Salinaka');
+
+ return (
+
+
+ }>
+
+
+
+
+ }>
+
+
+
+
+ }>
+
+
+
+
+ );
+};
+
+export default UserAccount;
diff --git a/src/views/admin/add_product/index.jsx b/src/views/admin/add_product/index.jsx
new file mode 100644
index 0000000..09e5614
--- /dev/null
+++ b/src/views/admin/add_product/index.jsx
@@ -0,0 +1,54 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { useDocumentTitle, useScrollTop } from 'hooks';
+import React, { lazy, Suspense } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { addProduct } from 'redux/actions/productActions';
+
+const ProductForm = lazy(() => import('../components/ProductForm'));
+
+const AddProduct = () => {
+ useScrollTop();
+ useDocumentTitle('Add New Product | Salinaka');
+ const isLoading = useSelector((state) => state.app.loading);
+ const dispatch = useDispatch();
+
+ const onSubmit = (product) => {
+ dispatch(addProduct(product));
+ };
+
+ return (
+
+
Add New Product
+
+ Loading ...
+
+
+
+ )}
+ >
+
+
+
+ );
+};
+
+export default withRouter(AddProduct);
diff --git a/src/views/admin/components/ProductForm.jsx b/src/views/admin/components/ProductForm.jsx
new file mode 100644
index 0000000..0afeb1a
--- /dev/null
+++ b/src/views/admin/components/ProductForm.jsx
@@ -0,0 +1,336 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import { CheckOutlined, LoadingOutlined } from '@ant-design/icons';
+import { ImageLoader } from 'components/common';
+import {
+ CustomColorInput, CustomCreatableSelect, CustomInput, CustomTextarea
+} from 'components/formik';
+import {
+ Field, FieldArray, Form, Formik
+} from 'formik';
+import { useFileHandler } from 'hooks';
+import PropType from 'prop-types';
+import React from 'react';
+import * as Yup from 'yup';
+
+// Default brand names that I used. You can use what you want
+const brandOptions = [
+ { value: 'Salt Maalat', label: 'Salt Maalat' },
+ { value: 'Betsin Maalat', label: 'Betsin Maalat' },
+ { value: 'Sexbomb', label: 'Sexbomb' },
+ { value: 'Black Kibal', label: 'Black Kibal' }
+];
+
+const FormSchema = Yup.object().shape({
+ name: Yup.string()
+ .required('Product name is required.')
+ .max(60, 'Product name must only be less than 60 characters.'),
+ brand: Yup.string()
+ .required('Brand name is required.'),
+ price: Yup.number()
+ .positive('Price is invalid.')
+ .integer('Price should be an integer.')
+ .required('Price is required.'),
+ description: Yup.string()
+ .required('Description is required.'),
+ maxQuantity: Yup.number()
+ .positive('Max quantity is invalid.')
+ .integer('Max quantity should be an integer.')
+ .required('Max quantity is required.'),
+ keywords: Yup.array()
+ .of(Yup.string())
+ .min(1, 'Please enter at least 1 keyword for this product.'),
+ sizes: Yup.array()
+ .of(Yup.number())
+ .min(1, 'Please enter a size for this product.'),
+ isFeatured: Yup.boolean(),
+ isRecommended: Yup.boolean(),
+ availableColors: Yup.array()
+ .of(Yup.string().required())
+ .min(1, 'Please add a default color for this product.')
+});
+
+const ProductForm = ({ product, onSubmit, isLoading }) => {
+ const initFormikValues = {
+ name: product?.name || '',
+ brand: product?.brand || '',
+ price: product?.price || 0,
+ maxQuantity: product?.maxQuantity || 0,
+ description: product?.description || '',
+ keywords: product?.keywords || [],
+ sizes: product?.sizes || [],
+ isFeatured: product?.isFeatured || false,
+ isRecommended: product?.isRecommended || false,
+ availableColors: product?.availableColors || []
+ };
+
+ const {
+ imageFile,
+ isFileLoading,
+ onFileChange,
+ removeImage
+ } = useFileHandler({ image: {}, imageCollection: product?.imageCollection || [] });
+
+ const onSubmitForm = (form) => {
+ if (imageFile.image.file || product.imageUrl) {
+ onSubmit({
+ ...form,
+ quantity: 1,
+ // due to firebase function billing policy, let's add lowercase version
+ // of name here instead in firebase functions
+ name_lower: form.name.toLowerCase(),
+ dateAdded: new Date().getTime(),
+ image: imageFile?.image?.file || product.imageUrl,
+ imageCollection: imageFile.imageCollection
+ });
+ } else {
+ // eslint-disable-next-line no-alert
+ alert('Product thumbnail image is required.');
+ }
+ };
+
+ return (
+
+
+ {({ values, setValues }) => (
+
+ )}
+
+
+ );
+};
+
+ProductForm.propTypes = {
+ product: PropType.shape({
+ name: PropType.string,
+ brand: PropType.string,
+ price: PropType.number,
+ maxQuantity: PropType.number,
+ description: PropType.string,
+ keywords: PropType.arrayOf(PropType.string),
+ imageCollection: PropType.arrayOf(PropType.object),
+ sizes: PropType.arrayOf(PropType.string),
+ image: PropType.string,
+ imageUrl: PropType.string,
+ isFeatured: PropType.bool,
+ isRecommended: PropType.bool,
+ availableColors: PropType.arrayOf(PropType.string)
+ }).isRequired,
+ onSubmit: PropType.func.isRequired,
+ isLoading: PropType.bool.isRequired
+};
+
+export default ProductForm;
diff --git a/src/views/admin/components/ProductItem.jsx b/src/views/admin/components/ProductItem.jsx
new file mode 100644
index 0000000..b8e0de3
--- /dev/null
+++ b/src/views/admin/components/ProductItem.jsx
@@ -0,0 +1,133 @@
+import { ImageLoader } from 'components/common';
+import { EDIT_PRODUCT } from 'constants/routes';
+import { displayActionMessage, displayDate, displayMoney } from 'helpers/utils';
+import PropType from 'prop-types';
+import React, { useRef } from 'react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { useDispatch } from 'react-redux';
+import { useHistory, withRouter } from 'react-router-dom';
+import { removeProduct } from 'redux/actions/productActions';
+
+const ProductItem = ({ product }) => {
+ const dispatch = useDispatch();
+ const history = useHistory();
+ const productRef = useRef(null);
+
+ const onClickEdit = () => {
+ history.push(`${EDIT_PRODUCT}/${product.id}`);
+ };
+
+ const onDeleteProduct = () => {
+ productRef.current.classList.toggle('item-active');
+ };
+
+ const onConfirmDelete = () => {
+ dispatch(removeProduct(product.id));
+ displayActionMessage('Item successfully deleted');
+ productRef.current.classList.remove('item-active');
+ };
+
+ const onCancelDelete = () => {
+ productRef.current.classList.remove('item-active');
+ };
+
+ return (
+
+
+
+
+ {product.image ? (
+
+ ) : }
+
+
+ {product.name || }
+
+
+ {product.brand || }
+
+
+ {product.price ? displayMoney(product.price) : }
+
+
+
+ {product.dateAdded ? displayDate(product.dateAdded) : }
+
+
+
+ {product.maxQuantity || }
+
+
+ {product.id && (
+
+
+ Edit
+
+
+
+ Delete
+
+
+
Are you sure you want to delete this?
+
+ No
+
+
+
+ Yes
+
+
+
+ )}
+
+
+ );
+};
+
+ProductItem.propTypes = {
+ product: PropType.shape({
+ id: PropType.string,
+ name: PropType.string,
+ brand: PropType.string,
+ price: PropType.number,
+ maxQuantity: PropType.number,
+ description: PropType.string,
+ keywords: PropType.arrayOf(PropType.string),
+ imageCollection: PropType.arrayOf(PropType.object),
+ sizes: PropType.arrayOf(PropType.string),
+ image: PropType.string,
+ imageUrl: PropType.string,
+ isFeatured: PropType.bool,
+ isRecommended: PropType.bool,
+ dateAdded: PropType.number,
+ availableColors: PropType.arrayOf(PropType.string)
+ }).isRequired
+};
+
+export default withRouter(ProductItem);
diff --git a/src/views/admin/components/ProductsNavbar.jsx b/src/views/admin/components/ProductsNavbar.jsx
new file mode 100644
index 0000000..24d8b14
--- /dev/null
+++ b/src/views/admin/components/ProductsNavbar.jsx
@@ -0,0 +1,45 @@
+import { FilterOutlined, PlusOutlined } from '@ant-design/icons';
+import { FiltersToggle, SearchBar } from 'components/common';
+import { ADD_PRODUCT } from 'constants/routes';
+import PropType from 'prop-types';
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+
+const ProductsNavbar = (props) => {
+ const { productsCount, totalProductsCount } = props;
+ const history = useHistory();
+
+ return (
+
+
+ Products
+ (
+ {`${productsCount} / ${totalProductsCount}`}
+ )
+
+
+
+
+
+
+ More Filters
+
+
+
history.push(ADD_PRODUCT)}
+ type="button"
+ >
+
+ Add New Product
+
+
+ );
+};
+
+ProductsNavbar.propTypes = {
+ productsCount: PropType.number.isRequired,
+ totalProductsCount: PropType.number.isRequired
+};
+
+export default ProductsNavbar;
diff --git a/src/views/admin/components/ProductsTable.jsx b/src/views/admin/components/ProductsTable.jsx
new file mode 100644
index 0000000..092f391
--- /dev/null
+++ b/src/views/admin/components/ProductsTable.jsx
@@ -0,0 +1,47 @@
+/* eslint-disable react/forbid-prop-types */
+import PropType from 'prop-types';
+import React from 'react';
+import { ProductItem } from '.';
+
+const ProductsTable = ({ filteredProducts }) => (
+
+ {filteredProducts.length > 0 && (
+
+
+
+
Name
+
+
+
Brand
+
+
+
Price
+
+
+
Date Added
+
+
+
Qty
+
+
+ )}
+ {filteredProducts.length === 0 ? new Array(10).fill({}).map((product, index) => (
+
+ )) : filteredProducts.map((product) => (
+
+ ))}
+
+);
+
+ProductsTable.propTypes = {
+ filteredProducts: PropType.array.isRequired
+};
+
+export default ProductsTable;
diff --git a/src/views/admin/components/index.js b/src/views/admin/components/index.js
new file mode 100644
index 0000000..481a415
--- /dev/null
+++ b/src/views/admin/components/index.js
@@ -0,0 +1,4 @@
+export { default as ProductForm } from './ProductForm';
+export { default as ProductItem } from './ProductItem';
+export { default as ProductsNavbar } from './ProductsNavbar';
+
diff --git a/src/views/admin/dashboard/index.jsx b/src/views/admin/dashboard/index.jsx
new file mode 100644
index 0000000..2509879
--- /dev/null
+++ b/src/views/admin/dashboard/index.jsx
@@ -0,0 +1,15 @@
+import { useDocumentTitle, useScrollTop } from 'hooks';
+import React from 'react';
+
+const Dashboard = () => {
+ useDocumentTitle('Welcome | Admin Dashboard');
+ useScrollTop();
+
+ return (
+
+
Welcome to admin dashboard
+
+ );
+};
+
+export default Dashboard;
diff --git a/src/views/admin/edit_product/index.jsx b/src/views/admin/edit_product/index.jsx
new file mode 100644
index 0000000..dc546ed
--- /dev/null
+++ b/src/views/admin/edit_product/index.jsx
@@ -0,0 +1,53 @@
+import { LoadingOutlined } from '@ant-design/icons';
+import { useDocumentTitle, useProduct, useScrollTop } from 'hooks';
+import PropType from 'prop-types';
+import React, { lazy, Suspense } from 'react';
+import { useDispatch } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { editProduct } from 'redux/actions/productActions';
+
+const ProductForm = lazy(() => import('../components/ProductForm'));
+
+const EditProduct = ({ match }) => {
+ useDocumentTitle('Edit Product | Salinaka');
+ useScrollTop();
+ const { product, error, isLoading } = useProduct(match.params.id);
+ const dispatch = useDispatch();
+
+ const onSubmitForm = (updates) => {
+ dispatch(editProduct(product.id, updates));
+ };
+
+ return (
+
+ {error && }
+
Edit Product
+ {product && (
+
+ Loading ...
+
+
+
+ )}
+ >
+
+
+ )}
+
+ );
+};
+
+EditProduct.propTypes = {
+ match: PropType.shape({
+ params: PropType.shape({
+ id: PropType.string
+ })
+ }).isRequired
+};
+
+export default withRouter(EditProduct);
diff --git a/src/views/admin/products/index.jsx b/src/views/admin/products/index.jsx
new file mode 100644
index 0000000..f7a95c5
--- /dev/null
+++ b/src/views/admin/products/index.jsx
@@ -0,0 +1,39 @@
+/* eslint-disable react/jsx-props-no-spreading */
+import { Boundary } from 'components/common';
+import { AppliedFilters, ProductList } from 'components/product';
+import { useDocumentTitle, useScrollTop } from 'hooks';
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { selectFilter } from 'selectors/selector';
+import { ProductsNavbar } from '../components';
+import ProductsTable from '../components/ProductsTable';
+
+const Products = () => {
+ useDocumentTitle('Product List | Salinaka Admin');
+ useScrollTop();
+
+ const store = useSelector((state) => ({
+ filteredProducts: selectFilter(state.products.items, state.filter),
+ requestStatus: state.app.requestStatus,
+ isLoading: state.app.loading,
+ products: state.products
+ }));
+
+ return (
+