Browse Source

HW Store done

suslov-dmytro 1 year ago
parent
commit
121a7cadad
9 changed files with 1087 additions and 0 deletions
  1. BIN
      Store/img/logo.png
  2. BIN
      Store/img/piggy-bank.png
  3. BIN
      Store/img/preloader.gif
  4. BIN
      Store/img/shopping-cart.png
  5. BIN
      Store/img/user.png
  6. 59 0
      Store/index.html
  7. 655 0
      Store/script.js
  8. 309 0
      Store/style.css
  9. 64 0
      Store/task.txt

BIN
Store/img/logo.png


BIN
Store/img/piggy-bank.png


BIN
Store/img/preloader.gif


BIN
Store/img/shopping-cart.png


BIN
Store/img/user.png


+ 59 - 0
Store/index.html

@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" href="style.css">
+    <link rel="icon" type="image/x-icon" href="img/piggy-bank.png">
+    <title>Store</title>
+</head>
+
+<body>
+    <header>
+        <nav class="navbar">
+            <a class="logo" href="index.html">
+                <img src="img/logo.png" alt="logo">
+            </a>
+            <div class="menu">
+                <div id="auth">
+                    <form id="form_no">
+                        <a href="#/register" type="submit">REGISTER</a>
+    
+                        <a href="#/login">LOGIN</a>
+                    </form>
+                    <div id="dropdown-yes">
+                        <form id="form_yes">
+    
+                            <a href="#/orderFind" id="UserNick"></a>
+                            <a onclick="store.dispatch(actionAuthLogout());display()" class="log-btn"
+                                type="submit">LOGOUT</a>
+                        </form>
+                    </div>    
+                </div>
+                <ul class="ul-menu">
+                    <li class="item">
+                        <a href="#/cart" id='cart-count'>
+                            <img src="img/shopping-cart.png" alt="shopping-cart">
+                        </a>
+                    </li>
+
+                </ul>
+
+            </div>
+        </nav>
+    </header>
+    <div id='mainContainer'>
+        <aside id="aside">
+            <ul id='cat'>
+            </ul>
+        </aside>
+        <main id='main'>
+        </main>
+
+    </div>
+    <script src='script.js'></script>
+</body>
+
+</html>

+ 655 - 0
Store/script.js

@@ -0,0 +1,655 @@
+// createstore
+
+function createStore(reducer) {
+    let state = reducer(undefined, {}) //стартовая инициализация состояния, запуск редьюсера со state === undefined
+    let cbs = []                     //массив подписчиков
+
+    const getState = () => state            //функция, возвращающая переменную из замыкания
+    const subscribe = cb => (cbs.push(cb),   //запоминаем подписчиков в массиве
+        () => cbs = cbs.filter(c => c !== cb)) //возвращаем функцию unsubscribe, которая удаляет подписчика из списка
+
+    const dispatch = action => {
+        if (typeof action === 'function') { //если action - не объект, а функция
+            return action(dispatch, getState) //запускаем эту функцию и даем ей dispatch и getState для работы
+        }
+        const newState = reducer(state, action) //пробуем запустить редьюсер
+        if (newState !== state) { //проверяем, смог ли редьюсер обработать action
+            state = newState //если смог, то обновляем state 
+            for (let cb of cbs) cb() //и запускаем подписчиков
+        }
+    }
+
+    return {
+        getState,
+        dispatch,
+        subscribe
+    }
+}
+
+// ----------------------------------------------------------
+
+// decode token
+
+function jwtDecode(token) {
+    try {
+        return JSON.parse(atob(token.split('.')[1]))
+    }
+    catch (e) {
+    }
+}
+
+// ----------------------------------------------------------
+
+// reducers
+
+function authReducer(state = {}, { type, token }) { //authorise reducer
+    if (state === undefined) {
+        if (localStorage.authToken) {
+            type = "AUTH_LOGIN";
+            token = localStorage.authToken;
+        }
+    }
+    if (type === 'AUTH_LOGIN') { //то мы логинимся
+        const payload = jwtDecode(token)
+        if (payload) {
+            return {
+                token,
+                payload
+            }
+        }
+    }
+    if (type === 'AUTH_LOGOUT') { //мы разлогиниваемся
+        return {}
+    }
+    return state
+}
+
+
+function promiseReducer(state = {}, { type, name, status, payload, error }) { //promise reducer
+    if (type === 'PROMISE') {
+        return {
+            ...state,
+            [name]: { status, payload, error }
+        }
+    }
+    return state
+}
+
+function cartReducer(state = {}, { type, good, count = 1 }) {
+    if (type === 'CART_ADD') {
+        return {
+            ...state,
+            [good._id]: { count: (state[good._id]?.count || 0) + count, good: good }
+        }
+    }
+    if (type === 'CART_CHANGE') {
+        return {
+            ...state,
+            [good._id]: { count, good }
+        }
+    }
+    if (type === 'CART_DELETE') {
+        delete state[good._id]
+        return {
+            ...state,
+        }
+    }
+    if (type === 'CART_CLEAR') {
+        return {}
+    }
+    return state
+}
+
+const actionCartAdd = (good, count = 1) => ({ type: 'CART_ADD', good, count })
+const actionCartChange = (good, count = 1) => ({ type: 'CART_CHANGE', good, count })
+const actionCartDelete = (good) => ({ type: 'CART_DELETE', good })
+const actionCartClear = () => ({ type: 'CART_CLEAR' })
+
+function combineReducers(reducers) {
+    function combinedReducer(combinedState = {}, action) {
+        const newCombinedState = {}
+        for (const [reducerName, reducer] of Object.entries(reducers)) {
+            const newSubState = reducer(combinedState[reducerName], action)
+            if (newSubState !== combinedState[reducerName]) {
+                newCombinedState[reducerName] = newSubState
+            }
+        }
+        if (Object.keys(newCombinedState).length === 0) {
+            return combinedState
+        }
+        return { ...combinedState, ...newCombinedState }
+    }
+
+    return combinedReducer
+}
+
+const store = createStore(combineReducers({ promise: promiseReducer, auth: authReducer, cart: cartReducer }))
+// store.subscribe(() => console.log(store.getState()))
+
+// ----------------------------------------------------------
+
+// GQL
+
+const getGQL = url =>
+    (query, variables) => fetch(url, {
+        method: 'POST',
+        headers: {
+            "Content-Type": "application/json",
+            ...(localStorage.authToken ? { "Authorization": "Bearer " + localStorage.authToken } : {})
+        },
+        body: JSON.stringify({ query, variables })
+    }).then(res => res.json())
+        .then(data => {
+            if (data.data) {
+                return Object.values(data.data)[0]
+            }
+            else throw new Error(JSON.stringify(data.errors))
+        })
+
+const backendURL = 'http://shop-roles.node.ed.asmer.org.ua/'
+
+const gql = getGQL(backendURL + 'graphql')
+
+// ----------------------------------------------------------
+
+// promises
+
+const actionPromise = (name, promise) =>
+    async dispatch => {
+        dispatch(actionPending(name))
+        try {
+            let payload = await promise
+            dispatch(actionFulfilled(name, payload))
+            return payload
+        }
+        catch (e) {
+            dispatch(actionRejected(name, e))
+        }
+    }
+
+const actionPending = (name) => ({ type: 'PROMISE', status: 'PENDING', name })
+const actionFulfilled = (name, payload) => ({ type: 'PROMISE', status: 'FULFILLED', name, payload })
+const actionRejected = (name, error) => ({ type: 'PROMISE', status: 'REJECTED', name, error })
+
+// ----------------------------------------------------------
+
+// actions
+
+const actionFullRegister = (login, password) =>
+    actionPromise('fullRegister', gql(`mutation UserUpsert($login: String, $password: String){UserUpsert(user: {login:$login,password:$password}){_id}}`, { login: login, password: password }))
+
+
+const actionAuthLogin = (token) =>
+    (dispatch, getState) => {
+        const oldState = getState()
+        dispatch({ type: 'AUTH_LOGIN', token })
+        const newState = getState()
+        if (oldState !== newState)
+            localStorage.authToken = token
+    }
+
+const actionAuthLogout = () =>
+    dispatch => {
+        dispatch({ type: 'AUTH_LOGOUT' })
+        localStorage.removeItem('authToken')
+    }
+
+const actionFullLogin = (login, password) =>
+    actionPromise('fullLogin', gql(`query login($login:String,$password:String){login(login:$login,password:$password)}`, { login: login, password: password }))
+
+
+const orderFind = () =>
+    actionPromise('orderFind', gql(`query orderFind{
+    OrderFind(query: "[{}]"){
+        _id createdAt total orderGoods {_id price count good{name price images{url}}}
+    }
+}`, { q: JSON.stringify([{}]) }))
+
+const actionAddOrder = (cart) =>
+    actionPromise('actionAddOrder', gql(`mutation newOrder($cart: [OrderGoodInput])
+{OrderUpsert(order: {orderGoods: $cart})
+{_id total}}`, { cart: cart }))
+
+const actionRootCats = () =>
+    actionPromise('rootCats', gql(`query {
+        CategoryFind(query: "[{\\"parent\\":null}]"){
+            _id name
+            
+        }
+    }`))
+
+const actionCatById = (_id) => // √
+    actionPromise('catById', gql(`query catById($q: String){
+        CategoryFindOne(query: $q){
+            _id name goods {
+                _id name price images {
+                    url
+                }
+                
+            }
+            subCategories{_id name}
+            
+        }
+    }`, { q: JSON.stringify([{ _id }]) }))
+
+const actionGoodById = (_id) =>
+    actionPromise('goodById', gql(`query goodById($q: String){
+        GoodFindOne(query: $q){
+            _id name price description images {
+                url
+            }
+        }
+    }`, { q: JSON.stringify([{ _id }]) }))
+
+store.dispatch(actionRootCats())
+
+// ----------------------------------------------------------
+
+// subscribe
+
+store.subscribe(() => {
+
+    const { rootCats } = (store.getState()).promise
+    if (rootCats?.payload) {
+        cat.innerHTML = `<li class="list-group-item"><b>Categories</b></li>`
+
+        for (const { _id, name } of rootCats?.payload) {
+            const categories = document.createElement('li')
+            categories.innerHTML = `<a href='#/category/${_id}'>${name}</a>`
+            categories.style = ' padding-left: 30px ; '
+
+            cat.append(categories)
+        }
+    }
+    else if (!rootCats) {
+        cat.innerHTML = '<img src = img/preloader.gif>'
+    }
+})
+
+store.subscribe(() => {
+    const { catById } = (store.getState()).promise
+    const [, route, _id] = location.hash.split('/')
+    if (catById?.payload && route === 'category') {
+
+        main.innerHTML = ``
+        const { name } = catById.payload
+
+        const card = document.createElement('div')
+        card.id = 'sub-card'
+        card.innerHTML = `<h2 id="cat-name"><b>${name}</b></h2><br>`
+
+        const backPart = document.createElement('div')
+        backPart.id = 'back'
+        const backBtn = document.createElement('button')
+        backBtn.setAttribute('type', 'button');
+        backBtn.addEventListener('click', () => {
+            history.back();
+        });
+        backBtn.innerText = '⬅'
+        backPart.appendChild(backBtn)
+
+
+        if (catById.payload.subCategories) {
+            for (const { _id, name } of catById.payload?.subCategories) {
+                card.innerHTML += `<a href='#/category/${_id}' class='subcategories'>${name}</a>`
+            }
+        }
+
+        // card.append(backPart)
+        main.append(card, backPart)
+        for (const { _id, name, price, images } of catById.payload?.goods) {
+            const card = document.createElement('div')
+            card.id = 'card'
+            card.innerHTML = `<h5><b>${name}</b></h5>
+                              <div class='card-img'><img src="http://shop-roles.node.ed.asmer.org.ua/${images[0].url}"/></div><br>
+                              <h2 style='color: green; text-align:center'>Price: $${price}</h2><br><br>
+                              <a class="" style="width: 100%;" href='#/good/${_id}'>More details -> </a><br><br>`
+            let button = document.createElement('button')
+            button.innerText = 'BUY'
+            button.className = 'buy-btn'
+            button.setAttribute('type', 'button');
+            button.onclick = async () => {
+                await store.dispatch(actionCartAdd({ _id: _id, name: name, price: price, images: images }))
+                console.log('hi')
+            }
+            card.append(button)
+            main.append(card)
+        }
+    }
+})
+
+store.subscribe(() => {
+    const { goodById } = (store.getState()).promise
+    const [, route, _id] = location.hash.split('/')
+    if (goodById?.payload && route === 'good') {
+        const { _id, name, description, images, price } = goodById.payload
+        main.innerHTML = `<h1>${name}</h1>`
+
+        const card = document.createElement('div')
+        card.id = 'desc-card'
+
+        const backPart = document.createElement('div')
+        backPart.id = 'back'
+
+        const backBtn = document.createElement('button')
+        backBtn.setAttribute('type', 'button');
+        backBtn.addEventListener('click', () => {
+            history.back();
+        });
+        backBtn.innerText = '⬅'
+        backPart.appendChild(backBtn)
+
+        let block = document.createElement('div')
+        block.id = 'price'
+        block.innerHTML = `<h2>Price: <b class='price'>$${price}</b></h2>`
+        let button = document.createElement('button')
+        button.innerText = 'BUY'
+        button.className = 'buy-btn'
+        button.setAttribute('type', 'button');
+        button.style = 'height:80px'
+        button.onclick = async () => {
+            await store.dispatch(actionCartAdd({ _id: _id, name: name, price: price, images: images }))
+            console.log('hi')
+        }
+
+        card.innerHTML = `<img src="http://shop-roles.node.ed.asmer.org.ua/${images[0].url}" /><br><br>`
+        card.append(block)
+        card.innerHTML += `<p><b>Description:</b> ${description}</p>`
+        main.append(backPart, card, button)
+    }
+})
+
+store.subscribe(() => {
+    const { orderFind } = (store.getState()).promise
+    const [, route, _id] = location.hash.split('/')
+
+    if (orderFind?.payload && route === 'orderFind') {
+        main.innerHTML = '<h1>ORDER HISTORY</h1>'
+
+        for (const { _id, createdAt, total, orderGoods } of orderFind.payload.reverse()) {
+            const card = document.createElement('div')
+            card.className = 'order-card'
+            card.innerHTML = `<h3>Order: ${createdAt}</h3>`
+            for (const { count, good } of orderGoods) {
+
+                const divGood = document.createElement('div')
+                divGood.style = "display:flex;margin-bottom: 20px;"
+
+                divGood.innerHTML += `<div><b>${good.name}</b><br> Price: <b>$${good.price}</b><br> Amount: <b>${count} pt</b></b></div><img style="max-width: 80px;margin-right: 20px;display: block;margin-left: auto;" src="http://shop-roles.node.ed.asmer.org.ua/${good.images[0].url}"/><br><br>`
+                card.append(divGood)
+            }
+            card.innerHTML += 'Date: <b>' + new Date(+createdAt).toLocaleString().replace(/\//g, '.') + '</b>'
+            card.innerHTML += `<br><h2>Total: <b style="color:green;">$${total}</b></h2>`
+            main.append(card)
+        }
+    }
+})
+
+// ----------------------------------------------------------
+
+// window
+
+function display() {
+    let token = localStorage.authToken
+    if (token) {
+        form_yes.style.display = 'flex'
+        form_no.style.display = 'none'
+        UserNick.innerText = JSON.parse(window.atob(localStorage.authToken.split('.')[1])).sub.login
+    } else {
+        form_yes.style.display = 'none'
+        form_no.style.display = 'flex'
+    }
+}
+display()
+
+window.onhashchange = () => {
+    const [, route, _id] = location.hash.split('/')
+
+    const routes = {
+        category() {
+            store.dispatch(actionCatById(_id))
+        },
+
+        good() {
+            store.dispatch(actionGoodById(_id))
+        },
+
+        login() {
+            main.innerHTML = ''
+            let form = document.createElement('div')
+            let div1 = document.createElement('div')
+            let div2 = document.createElement('div')
+
+            div1.innerHTML = `<h1>LOGIN</h1>`
+            let loginInput = document.createElement('input')
+            loginInput.placeholder = 'Type your username'
+            loginInput.name = 'login'
+            div1.style.display = 'flex'
+            div1.style.flexDirection = 'column'
+            let passwordInput = document.createElement('input')
+            passwordInput.placeholder = 'Type your password'
+            passwordInput.name = 'password'
+            passwordInput.type = 'password';
+            div1.append(loginInput)
+            div2.append(passwordInput)
+
+            let button = document.createElement('button')
+            button.innerText = "LOGIN"
+            button.id = 'login-btn'
+            button.setAttribute('type', 'button');
+
+            button.onclick = async () => {
+
+                let tokenPromise = async () => await store.dispatch(actionFullLogin(loginInput.value, passwordInput.value))
+                let token = await tokenPromise()
+
+                if (token !== null) {
+                    store.dispatch(actionAuthLogin(token))
+                    display()
+                    document.location.href = "#/orderFind";
+                }
+                else {
+                    loginInput.value = ''
+                    passwordInput.value = ''
+                    alert("Incorrect username or password.")
+                    store.dispatch(actionAuthLogout())
+                }
+            }
+
+            form.append(div1, div2, button)
+            main.append(form)
+        },
+
+        register() {
+            main.innerHTML = ''
+            let form = document.createElement('div')
+            let div1 = document.createElement('div')
+            let div2 = document.createElement('div')
+
+            div1.innerHTML += `<h1>REGISTER</h1>`
+            let loginInput = document.createElement('input')
+            loginInput.placeholder = "Type your username"
+            div1.append(loginInput)
+
+            let passwordInput = document.createElement('input')
+            passwordInput.placeholder = "Type your password"
+            passwordInput.type = 'password'
+
+            div2.append(passwordInput)
+
+            let button = document.createElement('button')
+            button.innerText = "CREATE ACCOUNT"
+            button.id = 'reg-btn'
+            button.setAttribute('type', 'button');
+
+            let textAlert = document.createElement('div')
+            let textAlert2 = document.createElement('div')
+
+            let putInText = "Username and password required!"
+            let userAlready = "An account with this username already exist!"
+            textAlert.append(userAlready)
+            textAlert2.append(putInText)
+
+            textAlert2.style = 'display : none; color : red'
+            textAlert.style = 'display : none; color : red'
+
+
+            button.onclick = async () => {
+                let register = await store.dispatch(actionFullRegister(loginInput.value, passwordInput.value))
+                let tokenPromise = async () => await store.dispatch(actionFullLogin(loginInput.value, passwordInput.value))
+
+                if (loginInput.value == '' || passwordInput.value == '') {
+                    textAlert2.style.display = 'block'
+
+                } else {
+                    if (register !== null) {
+                        let token = await tokenPromise()
+                        store.dispatch(actionAuthLogin(token))
+                        // console.log(token)
+                        display()
+                        document.location.href = "#/orderFind";
+
+                    } else {
+                        textAlert.style.display = 'block'
+                        textAlert2.style.display = 'none'
+                    }
+                }
+
+            }
+
+            form.append(div1, div2, button)
+            form.append(textAlert, textAlert2)
+            main.append(form)
+        },
+
+        orderFind() {
+            store.dispatch(orderFind())
+        },
+
+        cart() {
+            main.innerHTML = '<h1>CART</h1>'
+
+            for (const [_id, obj] of Object.entries(store.getState().cart)) {
+                const card = document.createElement('div')
+                card.style = 'width: 33.33%;border-style: groove;border-color: #ced4da17;padding: 10px;border-radius: 10px;margin: 5px;display: flex; flex-direction: column ; align-items: center ; justify-content: space-between'
+
+                const { count, good } = obj
+
+                card.innerHTML += `Products: <b>${good.name}</b> <br><img src="http://shop-roles.node.ed.asmer.org.ua/${good.images[0].url}" style="width: 100px"/> <br> Цена: <b>$${good.price}</b><br><br>`
+
+                const calculation = document.createElement('div')
+
+                const buttonAdd = document.createElement('button')
+                buttonAdd.innerHTML = '+'
+                buttonAdd.setAttribute('type', 'button');
+                buttonAdd.onclick = async () => {
+                    inputCount.value = +inputCount.value + 1
+                    await store.dispatch(actionCartChange({ _id: _id, name: good.name, price: good.price, images: good.images }, +inputCount.value))
+
+                    cardTotal.innerHTML = `<br><h2>Total: <b style="color:green;">$${goodPrice()}</b></h2><br>`
+                }
+                calculation.append(buttonAdd)
+
+                const inputCount = document.createElement('input')
+                inputCount.value = +count
+                inputCount.disabled = 'disabled'
+                inputCount.className = 'inputCount'
+                calculation.append(inputCount)
+
+                const buttonLess = document.createElement('button')
+                buttonLess.innerHTML = '-'
+                buttonLess.setAttribute('type', 'button');
+                buttonLess.onclick = async () => {
+                    if ((+inputCount.value) > 1) {
+                        inputCount.value = +inputCount.value - 1
+                        await store.dispatch(actionCartChange({ _id: _id, name: good.name, price: good.price, images: good.images }, +inputCount.value))
+
+                        cardTotal.innerHTML = `<br><h2>Total: <b style="color:green;">$${goodPrice()}</b></h2><br>`
+                    }
+
+                }
+
+                calculation.append(buttonLess)
+
+                const buttonDelete = document.createElement('button')
+                buttonDelete.innerText = 'Delete'
+                buttonDelete.className = 'buttonDelete'
+                buttonDelete.setAttribute('type', 'button');
+                buttonDelete.onclick = async () => {
+                    await store.dispatch(actionCartDelete({ _id: _id, name: good.name, price: good.price, images: good.images }))
+
+                    card.style.display = 'none'
+                    cardTotal.innerHTML = `<br><h2>Total: <b style="color:green;">$${goodPrice()}</b></h2><br>`
+                }
+                card.append(calculation)
+                card.append(buttonDelete)
+                main.append(card)
+            }
+            const cardTotalDiv = document.createElement('div')
+            cardTotalDiv.id = 'total'
+            const cardTotal = document.createElement('div')
+
+            cardTotal.innerHTML = `<br><h2>Total: <b style="color:green;">$${goodPrice()}</b></h2>`
+
+            cardTotalDiv.append(cardTotal)
+
+            let cartAlert = document.createElement('div')
+            cartAlert.innerHTML = `<h2 style='color:orange;'>Your cart seems empty 😟</h2>`
+            cartAlert.style.display = 'none'
+            cartAlert.id = 'cart-alert'
+
+            if (localStorage.authToken != '') {
+                const button = document.createElement('button')
+                button.innerHTML += '<strong>ORDER</strong>'
+                button.setAttribute('type', 'button');
+
+                if(goodPrice() != 0){
+                    button.onclick = async () => {
+                        await store.dispatch(actionAddOrder(Object.entries(store.getState().cart).map(([_id, count]) => ({ count: count.count, good: { _id } }))));
+                        await store.dispatch(actionCartClear());
+                        document.location.href = "#/orderFind";
+                    }    
+                }
+                else{
+                    cartAlert.style.display = 'flex'
+                }
+
+                // button.className = 'btn btn-primary'
+                cardTotalDiv.append(button)
+
+            }
+
+            main.append(cardTotalDiv, cartAlert)
+
+
+        }
+
+    }
+
+    if (route in routes) {
+        routes[route]()
+    }
+}
+window.onhashchange()
+
+function localStoredReducer(reducer, localStorageKey) {
+    function wrapperReducer(state, action) {
+        if (state === undefined) { //если загрузка сайта
+            try {
+                return JSON.parse(localStorage[localStorageKey]) //пытаемся распарсить сохраненный 
+                //в localStorage state и подсунуть его вместо результата редьюсера
+            }
+            catch (e) { } //если распарсить не выйдет, то код пойдет как обычно:
+        }
+        const newState = reducer(state, action)
+        localStorage.setItem(localStorageKey, JSON.stringify(newState)) //сохраняем состояние в localStorage
+        return newState
+    }
+    return wrapperReducer
+}
+
+window.onhashchange()
+
+function goodPrice() {
+    return Object.entries(store.getState().cart).map(i => x += i[1].count * i[1].good.price, x = 0).reverse()[0] || 0
+}

+ 309 - 0
Store/style.css

@@ -0,0 +1,309 @@
+html {
+    padding: 0;
+}
+
+body {
+    box-sizing: border-box;
+    font-family: 'Trebuchet MS';
+    overflow-x: hidden;
+    width: 100%;
+    margin: 0;
+}
+
+header {
+    position: sticky;
+    width: 100%;
+    top: 0;
+    left: 0;
+    z-index: 2;
+    margin: 0;
+}
+
+.navbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 5% 0;
+    border-bottom: 3px solid black;
+    border-top: 3px solid black;
+    background-color: white;
+}
+
+.menu {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.logo img {
+    height: 100px;
+}
+
+form {
+    padding: 10px;
+}
+
+#cart-count img {
+    height: 40px;
+    border-radius: 50%;
+    padding: 10px;
+}
+
+#cart-count img:hover {
+    background-color: lightgray;
+}
+
+li {
+    text-decoration: none;
+    list-style-type: none;
+    color: black;
+    font-size: 25px;
+    padding: 0 5% 5% 5%;
+}
+
+a {
+    cursor: pointer;
+    text-decoration: none;
+    list-style-type: none;
+    color: black;
+    font-size: 25px;
+    padding: 0 5% 0;
+
+}
+
+a:hover {
+    opacity: 50%;
+}
+
+input {
+    padding: 5px;
+    margin: 5px 0 5px 0;
+}
+
+button {
+    padding: 15px 35px;
+    margin-top: 20px;
+    background-color: lightgray;
+    text-transform: uppercase;
+    font-family: 'Trebuchet MS';
+    font-size: 25px;
+    border-radius: 25px;
+    border: 0;
+    text-align: center;
+}
+
+button:hover {
+    cursor: pointer;
+    opacity: 75%;
+}
+
+.log-btn {
+    color: black;
+    text-align: center;
+    border-radius: 5px;
+
+}
+
+#reg-btn {
+    padding: 15px 35px;
+    margin-top: 20px;
+    background-color: lightgray;
+    text-transform: uppercase;
+    font-family: 'Trebuchet MS';
+    font-size: 25px;
+    border-radius: 25px;
+    border: 0;
+}
+
+#reg-btn:hover {
+    opacity: 75%;
+}
+
+#mainContainer {
+    display: flex;
+    width: 100%;
+}
+
+#aside {
+    display: flex;
+    padding: 10px;
+}
+
+#cat {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    width: 25%;
+    min-width: 200px;
+    border-style: groove;
+    border-color: #ced4da17;
+    padding: 10px;
+    border-radius: 10px;
+}
+
+#cat>a {
+    display: block;
+}
+
+#cat-name {
+    margin: 0;
+}
+
+#main {
+    display: flex;
+    flex-wrap: wrap;
+    width: 70%;
+    padding: 20px;
+}
+
+h1 {
+    width: 100%;
+}
+
+h5 {
+    font-size: 1.25rem;
+    min-height: 50px;
+}
+
+#sub-card {
+    height: auto;
+    width: 100%;
+    border-style: groove;
+    border-color: #ced4da17;
+    padding: 20px 10px 20px 10px;
+    border-radius: 10px;
+    margin: 5px;
+}
+
+.subcategories {
+    background-color: lightgray;
+    border-radius: 25px;
+    margin-right: 10px;
+}
+
+.subcategories:hover {
+    opacity: 75%;
+}
+
+#cat a {
+    position: relative;
+    display: block;
+    margin: -0.5rem -1rem;
+    padding: 1rem 1rem;
+}
+
+#cat a:hover {
+    background-color: lightgray;
+    border-radius: 25px;
+}
+
+#card {
+    display: flex;
+    flex-direction: column;
+    height: auto;
+    width: 30%;
+    border-style: groove;
+    border-color: #ced4da17;
+    padding: 10px;
+    border-radius: 10px;
+    margin: 5px;
+}
+
+#card img {
+    display: block;
+    margin-left: auto;
+    margin-right: auto;
+    width: 50%;
+}
+
+.card-img {
+    display: block;
+    height: 100%;
+}
+
+.price {
+    color: green;
+}
+
+#desc-card {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    align-content: center;
+    align-items: center;
+}
+
+#back {
+    display: block;
+    padding: 15px 0 15px 0;
+    width: 100px;
+    padding-right: 100%;
+}
+
+#back button {
+    width: 100%;
+    font-family: 'Trebuchet MS';
+    letter-spacing: 1px;
+    padding: 15px 35px;
+    background-color: lightgray;
+    text-transform: uppercase;
+    font-size: 25px;
+    border-radius: 25px;
+    border: 0;
+}
+
+#back button:hover {
+    opacity: 75%;
+}
+
+.buy-btn {
+    width: 100%;
+    font-family: 'Trebuchet MS';
+    letter-spacing: 1px;
+    padding: 15px 35px;
+    margin-top: 20px;
+    background-color: lightgray;
+    text-transform: uppercase;
+    font-size: 25px;
+    border-radius: 25px;
+    border: 0;
+}
+
+.buy-btn:hover {
+    opacity: 75%;
+}
+
+.inputCount {
+    width: 35px;
+    text-align: center;
+    padding: 10px;
+    margin: 0 10px;
+}
+
+.order-card {
+    display: block;
+    border-style: groove;
+    border-color: #ced4da17;
+    padding: 10px;
+    border-radius: 10px;
+    margin: 5px;
+    width: 30%;
+}
+
+#total {
+    display : flex;
+    width: 100%;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+}
+
+#cart-alert{
+    display: flex;
+    width: 100%;
+    justify-content: center;
+}
+
+#main #desc-card{
+    display: flex;
+}

+ 64 - 0
Store/task.txt

@@ -0,0 +1,64 @@
+ДЗ 1:
+
+0) забрать код по ссылке выше; √
+
+используя store.subscribe и вашу функцию-подписчика:
+
+1) забрать rootCats (store.getState().rootCats.payload.data.CategoryFind) 
+при его наличии. Используйте ?. вместо . чтобы не получать ошибки Cannot read 
+properties of undefined. √ 
+
+2) проитерировать rootCats, создать для каждой категории ссылку (тэг a) 
+с текстом-названием категории вида #/category/АЙДИШНИК КАТЕГОРИИ. 
+Отобразите ссылке в блоке aside √
+
+3) пока промис в статусе PENDING используйте какой-то прелоадер (анимированную 
+картинку загрузки) вместо меню. Картинку нагуглите). Для симуляции тормозного 
+интернета используйте Network -> Throttling в Developer Tools √
+
+gql:
+
+- сделать функцию getGQL, которая возвращает функцию, похожую на текущую gql, 
+однако замыкает url (getGQL принимает url как параметр; gql же более не будет 
+требовать этого параметра) √
+- сделать параметр по умолчанию для variables - пустой объект; √
+- сделать reject возвращаемого промиса, если с бэкэнда не вернулся JSON с data, 
+зато в JSON есть errors. ошибка с бэка должна попасть error промиса; √
+- сделать так, что бы промис-результат gql был без лишних оберток типа 
+data.CategoryFind, data.GoodFindOne и т.п. Пусть результат gql содержит только 
+полезную информацию. Для этого из data извлеките значение первого ключа, как бы он 
+не назывался; √
+- если в localStorage есть authToken - добавить заголовок Authorization 
+со значением "Bearer ТОКЕН_ИЗ_ЛОКАЛСТОРЕЙДЖА" √
+
+-------------------------------------------------------------------------------------
+
+ДЗ 2:
+
+1. доделать страницу категории:
+    1.1 сделать нормальные карточки товаров с ценой и картинкой. Для картинки используйте 
+    ссылку http://shop-roles.node.ed.asmer.org.ua/ + url картинки; √
+    1.2 вверху над товарами сделать вывод ссылок на подкатегории (по аналогии с aside 
+    слева)
+
+2. Сделать страницу товара. Пусть там выводятся все картинки (можно сделать какой-то 
+слайдер, переключающий картинки по клику) + описание. Добавьте ссылку на 
+категорию(-и) в которых находится товар. 
+
+3. создать actionFullLogin, который thunk (возвращает функцию с параметром dispatch. 
+Thunk должен вначале диспатчить actionLogin (который промис login), получать его 
+результат (let result = await dispatch(actionLogin....., и если там не null, то 
+диспатчить actionAuthLogin с полученным токеном; 
+(http://doc.a-level.com.ua/javascript-redux-thunk#h3-8)
+
+4. создать actionFullRegister, который так же thunk:
+    4.1 диспатчит actionRegister (аналог actionLogin, создает юзера на бэке)
+    4.2 в случае успеха диспатчит actionFullLogin, для автоматического логина после 
+    реги. (http://doc.a-level.com.ua/javascript-redux-thunk#h3-9)
+
+5. добавить в window.onhashchange роуты #/login и #/register, которые используют 
+ваши конструкторы LoginForm для отрисовки формы. По событию формы логина onlogin 
+делайте store.dispatch(actionFullLogin(.....)) и 
+store.dispatch(actionFullRegister(.....)) соответственно;
+
+6. добавьте где-то вверху страницы имя пользователя когда пользователь залогинен.