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, //добавление функции getState в результирующий объект dispatch, subscribe //добавление subscribe в объект } } function promiseReducer(state={}, {type, name, status, payload, error}){ ////????? //ОДИН ПРОМИС: //состояние: PENDING/FULFILLED/REJECTED //результат //ошибка: //{status, payload, error} //{ // name1:{status, payload, error} // name2:{status, payload, error} // name3:{status, payload, error} //} if (type === 'PROMISE'){ return { ...state, [name]:{status, payload, error} } } return state } 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}) const actionPromise = (name, promise) => async dispatch => { try { dispatch(actionPending(name)) let payload = await promise dispatch(actionFulfilled(name, payload)) return payload } catch(e){ dispatch(actionRejected(name, e)) } } const goToMainPage = () => location.href = location.href.split("#")[0]; const checkAuthToken = () => { const headers = { "Content-Type": "application/json", "Accept": "application/json", } if(localStorage.getItem('authToken')) { return { ...headers, "Authorization": `Bearer ${localStorage.getItem('authToken')}` } } else { return headers; } } const getGQL = url => (query, variables= {}) => fetch(url, { method: 'POST', headers: checkAuthToken(), body:JSON.stringify({query, variables}) }).then(res => res.json()) .then(data => { try { if(!data.data && data.errors) { throw new SyntaxError(`SyntaxError - ${JSON.stringify(Object.values(data.errors)[0])}`); } return Object.values(data.data)[0]; } catch (e) { console.error(e); } }); function jwtDecode(token){ try { return JSON.parse(atob(token.split('.')[1])) } catch(e){ } } function authReducer(state={}, {type, token}){ //{ // token,payload (раскодированный токен) //} или, если не залогинены //{ // нихрена, т. .е. пустой объект //} if (type === 'AUTH_LOGIN'){ //то мы логинимся const payload = jwtDecode(token) if (payload){ return { token, payload } } } if (type === 'AUTH_LOGOUT'){ //мы разлогиниваемся return {} } return state } const actionAuthLogout = () => dispatch => { dispatch({type: 'AUTH_LOGOUT'}); localStorage.removeItem('authToken'); goToMainPage() } const actionAuthLogin = (token) => (dispatch, getState) => { const oldState = getState() dispatch({type: 'AUTH_LOGIN', token}) const newState = getState() if (oldState !== newState) localStorage.setItem('authToken', token) } function cartReducer(state={}, {type, amount=1, good}){ /* { id1: {amount, good: {объект с бэка с _id, description, name, price}}, id2: {amount, good: {объект с бэка с _id, description, name, price}}, id3: {amount, good: {объект с бэка с _id, description, name, price}} } */ if (type === 'CART_ADD'){ return { ...state, [good._id]: {good, amount: (state[good._id]?.amount || 0) + amount } } } if (type === 'CART_SET'){ return { ...state, [good._id]: {good, amount} } } if (type === 'CART_DELETE'){ const {[good._id]: skip,...newState} = state //const newState = { ...state } //delete newState[good._id] return newState } if (type === 'CART_CLEAR'){ return {} } //if (type === ''){ //} return state } const actionCartAdd = (good, amount=1) => ({type: 'CART_ADD', good, amount}) const actionCartSet = (good, amount=1) => ({type: 'CART_SET', good, amount}) const actionCartClear = () => ({type: 'CART_CLEAR'}) const actionCartDelete = (good) => ({type: 'CART_DELETE', good}) function combineReducers(reducers){ function totalReducer(state={}, action){ //{ //promise:{ //name1:{status, payload, error}, //name2:{status, payload, error} //}, //auth: { //token, payload //} //} const newTotalState = {} for (const [reducerName, reducer] of Object.entries(reducers)){ const newSubState = reducer(state[reducerName], action) if (newSubState !== state[reducerName]){ newTotalState[reducerName] = newSubState } } if (Object.keys(newTotalState).length){ return {...state, ...newTotalState} } return state } return totalReducer } 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 } const reducers = { auth: authReducer, cart: localStoredReducer(cartReducer, 'cart'), //в localStorage должен появится ключ cart с JSON стейта корзины promise: localStoredReducer(promiseReducer, 'promise'), } const totalReducer = combineReducers(reducers) const store = createStore(totalReducer) //не забудьте combineReducers если он у вас уже есть store.subscribe(() => console.log(store.getState())) const backendURL = 'http://shop-roles.node.ed.asmer.org.ua/' const gql = getGQL(backendURL + 'graphql') const rootCategories = () => gql(`query cadz($q:String) { CategoryFind(query:$q){ _id name } }`, {q: JSON.stringify([{parent: null}])}) const actionRootCategories = () => actionPromise('rootCategories', rootCategories()) const categoryById = _id => //добавьте сюда подкатегории и родителя - пригодятся gql(`query catById($qCat:String){ CategoryFindOne(query:$qCat){ _id name parent { _id name } subCategories { name _id parent { _id name } } goods{ _id name price images{ url } } } }`, {qCat: JSON.stringify([{_id}])}) const actionCategoryById = _id => actionPromise('catById', categoryById(_id)) const goodById = _id => gql(`query goodById($goodId:String) { GoodFindOne(query:$goodId) { _id name price description images { url } } }`, {goodId: JSON.stringify([{_id}])}) const actionGoodById = _id => actionPromise('goodById', goodById(_id)); const actionOrder = () => async (dispatch, getState) => { const order = Object.values(getState().cart).map(orderGoods => ({good: {_id: orderGoods.good._id}, count: orderGoods.amount})); const myOrder = await dispatch(actionPromise('myOrder', gql(`mutation myOrder($order:OrderInput) { OrderUpsert(order:$order) { _id createdAt total } }`, {order: {orderGoods: order}}))); if(myOrder) { dispatch(actionCartClear()); } } const orders = () => gql(`query myOrders { OrderFind(query:"[{}]"){ _id total orderGoods{ price count total good{ _id name images{ url } } } } }`, {}) const actionOrders = () => actionPromise('myOrders', orders()) const actionLogin = (login, password) => actionPromise('login', gql(`query log($login:String, $password:String) { login(login:$login, password:$password) }`, {login, password})); const actionRegister = (login, password) => actionPromise('register', gql(`mutation register($login:String, $password:String) { UserUpsert(user:{login:$login, password:$password}) { _id login createdAt } }`, {login, password})); const actionFullLogin = (login, password) => async dispatch => { const token = await dispatch(actionLogin(login, password)) if (token){ dispatch(actionAuthLogin(token)); goToMainPage() } } const actionFullRegister = (login, password) => async dispatch => { const user = await dispatch(actionRegister(login, password)) if(user) { dispatch(actionFullLogin(login, password)) } } const actionFullOrders = () => async dispatch => { await dispatch(actionOrders()); if(Object.keys(store.getState().auth).length === 0) { goToMainPage() } } function Password(parent, open) { const input = document.createElement('input'); input.id = 'password' input.type = 'password'; parent.appendChild(input); const button = document.createElement('button'); button.type = 'button'; button.textContent = 'показать'; parent.appendChild(button); button.addEventListener('click', () => { this.setOpen(open !== true); }); this.setValue = newValue => input.value = newValue; this.getValue = () => input.value; this.setOpen = openUpdate => { open = openUpdate; if(typeof this.onOpenChange === 'function') { this.onOpenChange(openUpdate); } button.textContent = (openUpdate) ? 'показать' : 'скрыть'; input.type = (openUpdate) ? 'password' : 'text'; } this.getOpen = () => open; input.addEventListener('input', event => { if (typeof this.onChange === 'function'){ this.onChange(event.target.value); } }); } function LoginForm(parent) { const createDivider = () => parent.appendChild(document.createElement('br')); const input = document.createElement('input'); input.id = 'login'; input.type = 'text'; parent.appendChild(input); const button = document.createElement('button'); button.type = 'button'; button.textContent = 'Логин'; button.disabled = true; input.addEventListener('input', event => { if (typeof this.onChange === 'function'){ this.onChange(event.target.value); } }); button.addEventListener('click', event => { if (typeof this.onLogin === 'function') { this.onLogin(event.target); } }); this.getLogin = () => input.value; createDivider(); const password = new Password(parent, true); const getPassword = () => password.getValue(); createDivider(); parent.appendChild(button); const isDisabled = () => button.disabled = (!(getPassword() !== '' && this.getLogin() !== '')); password.onChange = () => isDisabled(); this.onChange = () => isDisabled(); this.setButtonText = newText => button.textContent = newText; } store.subscribe(() => { const rootCats = store.getState().promise.rootCategories?.payload if (rootCats){ aside.innerHTML = '' for (let {_id, name} of rootCats){ const a = document.createElement('a') a.innerText = name a.href = `#/category/${_id}` aside.append(a) } } }) store.subscribe(() => { const catById = store.getState().promise.catById?.payload const [,route] = location.hash.split('/') if (catById && route === 'category'){ const {name, goods, parent, subCategories} = catById; main.innerHTML = `

${name}

`; if(parent) { const {_id, name} = parent; const breadcrumbs = document.createElement('a'); breadcrumbs.innerText = name; breadcrumbs.href = `#/category/${_id}`; main.prepend(breadcrumbs); } if(subCategories) { const listSubCategories = document.createElement('ul'); for (let {_id, name} of subCategories) { listSubCategories.innerHTML += `
  • ${name}
  • `; } main.append(listSubCategories); } for (let good of goods){ const {_id, name, price, images} = good const a = document.createElement('a') a.classList.add('card') a.innerHTML = `
    ${name}

    ${name}

    ${price}
    ` a.href = `#/good/${_id}` const button = document.createElement('button') button.type = 'button'; button.innerText = 'добавить в корзину' button.onclick = () => { store.dispatch(actionCartAdd(good)) } main.append(a) main.append(button) } } }) store.subscribe(() => { const goodById = store.getState().promise.goodById?.payload const [,route] = location.hash.split('/') if (goodById && route === 'good'){ const {name, description, price, images} = goodById main.innerHTML = `

    ${name}

    ${description}

    ${price}
    `; const button = document.createElement('button') button.type = 'button'; button.innerText = 'добавить в корзину' button.onclick = () => { store.dispatch(actionCartAdd(goodById)) } main.append(button); let imageGroup = document.createElement('div'); imageGroup.classList.add('good-images') for (const img in images) { imageGroup.innerHTML += `
    ${name} photo-${img}
    ` } main.append(imageGroup) } }) const drawCart = () => { const cart = store.getState().cart; const [,route] = location.hash.split('/'); if (cart && route === 'cart'){ if(Object.keys(cart).length === 0) { main.innerHTML = '

    Корзина пустая

    '; } else { main.innerHTML = ''; const cartBlock = document.createElement('div'); cartBlock.classList.add('cart'); const totalAmountByPosition = []; for (const good of Object.values(cart)) { const {_id, name, price, images} = good.good; totalAmountByPosition.push(price * good.amount); const cartElement = document.createElement('div'); cartElement.classList.add('cart__element') cartElement.innerHTML = `
    ${name}
    ${name} Цена - ${price}

    Количество - ${good.amount} шт.

    Итого по позиции - ${price * good.amount}

    `; const cartQuantity = document.createElement('fieldset'); cartQuantity.classList.add('cart__quantity'); const cartQuantityAmount = document.createElement('span'); const incDecButton = (text, classStr, incDec) => { const button = document.createElement('button'); button.type = 'button'; button.id = `${(incDec ? 'decrease' : 'increase')}` button.innerText = text; button.classList.add('cart__quantity-button', `cart__quantity-button--${classStr}`); button.onclick = () => { store.dispatch(actionCartAdd(good.good, (incDec ? -1 : +1))); if(cart[_id].amount > 1 && button.id === 'decrease') { button.disabled = false } } if(cart[_id].amount === 1 && button.id === 'decrease') { button.disabled = true; } return button; } cartQuantityAmount.classList.add('cart__quantity-amount'); cartQuantityAmount.innerText = good.amount; cartElement.append(cartQuantity); cartQuantity.append(incDecButton('-', 'decrease', true)); cartQuantity.append(cartQuantityAmount); cartQuantity.append(incDecButton('+', 'increase', false)); const deleteButton = document.createElement('button'); deleteButton.type = 'button' deleteButton.innerText = 'Удалить товар' deleteButton.classList.add('cart__delete-button') deleteButton.onclick = () => { store.dispatch(actionCartDelete(good.good)) } cartElement.append(deleteButton); cartBlock.append(cartElement); } main.append(cartBlock) const totalPrice = document.createElement('p'); totalPrice.innerHTML = `Итого - ${totalAmountByPosition.reduce((a, b) => a + b, 0)}`; main.append(totalPrice); const makeOrderButton = document.createElement('button'); makeOrderButton.type = 'button'; makeOrderButton.innerText = 'Оформить заказ'; makeOrderButton.classList.add('cart-button'); makeOrderButton.onclick = () => { store.dispatch(actionOrder()); } main.append(makeOrderButton) } } } store.subscribe(drawCart); store.subscribe(() => { const orders = store.getState().promise.myOrders?.payload; const [,route] = location.hash.split('/') if(orders && route === 'orderhistory') { main.innerHTML = '

    Мои заказы

    '; const orderCartGroup = document.createElement('div'); orderCartGroup.classList.add('order-cart-group'); for (const [index, value] of Object.entries(orders)) { const orderCartGroupElement = document.createElement('div'); orderCartGroupElement.classList.add('order-cart-group__element') orderCartGroupElement.innerHTML = `

    Заказ №${+index+1} (ID заказа - ${value._id})

    `; const orderCartElements = document.createElement('div'); orderCartElements.classList.add('order-cart__elements'); const totalAll = document.createElement('p'); totalAll.innerHTML = `

    Итого - ${value.total}

    `; for (const goodElement of Object.values(value.orderGoods)) { const {price, count, total, good} = goodElement; const orderCartElement = document.createElement('div'); orderCartElement.classList.add('order-cart__element'); orderCartElement.innerHTML = `
    ${good.name}

    ${good.name}

    Цена - ${price}

    Количество - ${count}

    Итого по позиции - ${total}

    `; orderCartElement.classList.add('order-cart__element'); orderCartElements.append(orderCartElement) } orderCartGroupElement.append(orderCartElements); orderCartGroupElement.append(totalAll); orderCartGroup.append(orderCartGroupElement) } main.append(orderCartGroup) } }) const drawUserName = () => { const buttonLogout = ''; const buttonLogin = 'Войти'; const buttonRegister = 'Регистрация'; authSection.innerHTML = store.getState().auth.token ? `Пользователь - ${store.getState().auth.payload.sub.login}
    ${buttonLogout}
    ` :`Пользователь - anon
    ${buttonLogin} ${buttonRegister}
    `; } drawUserName() //работаем безусловно при перезагрузке страницы store.subscribe(drawUserName) //а так же при обновлении redux // честно стырил отсюда - https://gist.github.com/realmyst/1262561?permalink_comment_id=2299442#gistcomment-2299442 const declOfNum = (n, titles) => titles[(n % 10 === 1 && n % 100 !== 11) ? 0 : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2]; const drawCardAmount = () => { if(store.getState().cart && store.getState().auth.token) { let totalAmount = Object.values(store.getState().cart).reduce((a, b) => a + b.amount, 0); cartIcon.innerHTML = `Корзина - ${totalAmount} ${declOfNum(totalAmount, ['товар', 'товара', 'товаров'])}`; } } drawCardAmount() store.subscribe(drawCardAmount) store.dispatch(actionAuthLogin(localStorage.authToken)) store.dispatch(actionRootCategories()) //#/category/АЙДИШНИК //#/good/АЙДИШНИК window.onhashchange = () => { const [,route, _id] = location.hash.split('/') const routes = { category() { store.dispatch(actionCategoryById(_id)) }, good(){ store.dispatch(actionGoodById(_id)) }, login(){ main.innerHTML = ''; const loginForm = new LoginForm(main); loginForm.onLogin = () => store.dispatch(actionFullLogin(login.value, password.value)) }, register(){ main.innerHTML = ''; const registerForm = new LoginForm(main); registerForm.setButtonText('Регистрация'); registerForm.onLogin = () => store.dispatch(actionFullRegister(login.value, password.value)); }, cart(){ drawCart(); }, orderhistory(){ store.dispatch(actionFullOrders()) } } if (route in routes){ routes[route]() } } window.onhashchange()