const API_URL = "http://shop-roles.node.ed.asmer.org.ua/graphql"; function getGql(url) { return (query, variables = {}) => { const token = store.getState().auth.token; return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' }, body: JSON.stringify({ query, variables }), }).then(response => response.json()).then(response => { if (response.data) { return Object.values(response.data)[0]; } else if (response.errors) { throw new Error(JSON.stringify(response.errors)); } }); } } const gql = getGql(API_URL); 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(state); //и запускаем подписчиков } } return { getState, //добавление функции getState в результирующий объект dispatch, subscribe //добавление subscribe в объект } } function combineReducers(reducers) { function totalReducer(state = {}, action) { 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; } const reducers = { promise: promiseReducer, //допилить много имен для многих промисо auth: authReducer, //часть предыдущего ДЗ cart: cartReducer, //часть предыдущего ДЗ } const totalReducer = combineReducers(reducers); function promiseReducer(state = {}, { key, type, status, payload, error }) { if (type === 'PROMISE') { return { ...state, [key]: { status, payload, error } }; } return state; } const actionPending = (key) => ({ key, type: 'PROMISE', status: 'PENDING' }); const actionFulfilled = (key, payload) => ({ key, type: 'PROMISE', status: 'FULFILLED', payload }); const actionRejected = (key, error) => ({ key, type: 'PROMISE', status: 'REJECTED', error }); const actionPromise = (key, promise) => async dispatch => { dispatch(actionPending(key)); //сигнализируем redux, что промис начался try { const payload = await promise; //ожидаем промиса dispatch(actionFulfilled(key, payload)); //сигнализируем redux, что промис успешно выполнен return payload; //в месте запуска store.dispatch с этим thunk можно так же получить результат промиса } catch (error) { dispatch(actionRejected(key, error)); //в случае ошибки - сигнализируем redux, что промис несложился main.innerHTML = '
'; } } function authReducer(state = {}, { type, token }) { if (type === 'AUTH_LOGIN') { try { let mediumStr = token.split('.')[1]; let result = JSON.parse(atob(mediumStr)); return { ...state, 'token': token, 'payload': result }; } catch (e) { return {}; } } if (type === 'AUTH_LOGOUT') { return {}; } return state; } const actionAuthLogin = token => ({ type: 'AUTH_LOGIN', token }); const actionAuthLogout = () => ({ type: 'AUTH_LOGOUT' }); function cartReducer(state = {}, { type, good, count }) { let goodKey, oldCount, goodValue; if (good) { goodKey = good['_id']; oldCount = state[goodKey]?.count || 0; goodValue = { good, count: oldCount }; } if (type === 'CART_ADD') { goodValue.count += +count; return { ...state, [goodKey]: goodValue }; } else if (type === 'CART_SUB') { goodValue.count -= +count; if (goodValue.count <= 0) { delete state[goodKey]; return { ...state }; } return { ...state, [goodKey]: goodValue }; } else if (type === 'CART_DEL') { delete state[goodKey]; return { ...state }; } else if (type === 'CART_SET') { goodValue.count = +count; if (goodValue.count <= 0) { delete state[goodKey]; return { ...state }; } return { ...state, [goodKey]: goodValue }; } else if (type === 'CART_CLEAR') { return {}; } return state; } const actionCartAdd = (good, count = 1) => ({ type: 'CART_ADD', count, good }); const actionCartSub = (good, count = 1) => ({ type: 'CART_SUB', count, good }); const actionCartDel = (good) => ({ type: 'CART_DEL', good }); const actionCartSet = (good, count = 1) => ({ type: 'CART_SET', count, good }); const actionCartClear = () => ({ type: 'CART_CLEAR' }); function localStoredReducer(originalReducer, localStorageKey) { function wrapper(state, action) { if (state === undefined) { try { let savedState = JSON.parse(localStorage[localStorageKey]); return savedState; } catch (e) { } } let newState = originalReducer(state, action); localStorage.setItem(localStorageKey, JSON.stringify(newState)); return newState; } return wrapper; } const store = createStore(localStoredReducer(totalReducer, 'total')); store.subscribe(() => console.log(store.getState())); function drawTitle(name) { let nameEl = document.createElement('div'); main.append(nameEl); nameEl.innerText = name; nameEl.classList.add('title'); } function drawCategoriesSection(categories, categoryEl) { let containerEl = document.createElement('section'); main.append(containerEl); containerEl.innerText = categoryEl; containerEl.classList.add('info-about-category'); for (const category of categories) { let categoryName = document.createElement('a'); categoryName.classList.add('link-style'); containerEl.append(categoryName); categoryName.href = `#/category/${category._id}`; categoryName.innerText = category.name; } } function drawImage(parent, url, route, id) { let imageContainer = document.createElement('div'); parent.append(imageContainer); if (route == 'category') { imageContainer.addEventListener('click', (e) => location.href = `#/good/${id}`); } imageContainer.classList.add('image-container'); let goodImage = document.createElement('img'); imageContainer.append(goodImage); goodImage.classList.add('good-image'); goodImage.src = 'http://shop-roles.node.ed.asmer.org.ua/' + url; } function drawSum(parent, priceText) { let sumEl = document.createElement('div'); parent.append(sumEl); sumEl.classList.add('price'); sumEl.innerText = priceText; } function drawCounter(parent, value) { let counterEl = document.createElement('input'); parent.append(counterEl); counterEl.type = 'number'; counterEl.min = 1; counterEl.value = value; return counterEl; } function drawAddButton(parent) { let cartAddButton = document.createElement('button'); parent.append(cartAddButton); cartAddButton.innerText = 'Add to cart'; cartAddButton.classList.add('button'); return cartAddButton; } function drawDelButton(parent) { let cartDelButton = document.createElement('button'); parent.append(cartDelButton); cartDelButton.innerText = 'Delete from cart'; cartDelButton.classList.add('button'); return cartDelButton; } const drawCategory = () => { const [, route] = location.hash.split('/'); if (route !== 'category') return; let status, payload, error; if (store.getState().promise.category) { ({ status, payload, error } = store.getState().promise.category); } if (status === 'PENDING') { main.innerHTML = ``; } if (status === 'FULFILLED') { main.innerHTML = ''; const { name, subCategories, parent, goods } = payload; drawTitle(name); if (subCategories?.length > 0) { drawCategoriesSection(subCategories, 'Subcategories: '); } if (parent) { drawCategoriesSection([parent], 'Parent category: '); } if (goods?.length > 0) { let goodsContainer = document.createElement('section'); main.append(goodsContainer); goodsContainer.classList.add('goods-container'); for (const good of goods) { let goodCart = document.createElement('div'); goodsContainer.append(goodCart); goodCart.classList.add('good-cart'); let goodName = document.createElement('a'); goodCart.append(goodName); goodName.href = `#/good/${good._id}`; goodName.innerText = good.name; if (good.images?.length > 0) { drawImage(goodCart, good.images[0].url, route, good._id); } if (good.price) { drawSum(goodCart, `${good.price} UAH`); } let goodCountEl = drawCounter(goodCart, 1); let cartAddButton = drawAddButton(goodCart); let cartDelButton = drawDelButton(goodCart); cartAddButton.addEventListener('click', event => { store.dispatch(actionCartAdd(good, goodCountEl.value)); }); cartDelButton.addEventListener('click', event => { store.dispatch(actionCartDel(good)); }); } } else { main.innerHTML += `
Goods out of stock
`; } } } store.subscribe(() => drawCategory()); function LoginForm(parent) { parent.innerHTML = ''; if (location.hash == '#/login') { let loginTitle = document.createElement('div'); parent.append(loginTitle); loginTitle.classList.add('title'); loginTitle.innerText = 'Account login'; } else if (location.hash == '#/register') { let registerTitle = document.createElement('div'); parent.append(registerTitle); registerTitle.classList.add('title'); registerTitle.innerText = 'Registration'; } let form = document.createElement('form'); parent.append(form); form.classList.add('form'); let loginLabel = document.createElement('label'); loginLabel.innerText = 'Login: '; form.append(loginLabel); let login = new Login(form); let passwordLabel = document.createElement('label'); passwordLabel.innerText = 'Password: '; form.append(passwordLabel); let password = new Password(form, false); let submit = document.createElement('button'); submit.classList.add('button'); submit.innerText = 'Submit'; form.append(submit); this.validateForm = () => { if (login.getValue() == '' || password.getValue() == '') { submit.disabled = true; } else { submit.disabled = false; if (this.onValidForm) { this.onValidForm(); } } } submit.addEventListener('click', (e) => { e.preventDefault(); if (this.onSubmitForm) { this.onSubmitForm(); } }); this.getLoginValue = () => { return login.getValue(); } this.setLoginValue = (value) => { login.setValue(value); } this.getPasswordValue = () => { return password.getValue(); } this.setPasswordValue = (value) => { password.setValue(value); } this.validateForm(); login.onChange = this.validateForm; password.onChange = this.validateForm; } function Login(parent) { let loginInputEl = document.createElement('input'); loginInputEl.type = 'text'; parent.append(loginInputEl); loginInputEl.addEventListener('input', (event) => { if (this.onChange) { this.onChange(); } }); this.setValue = (value) => { loginInputEl.value = value; } this.getValue = () => { return loginInputEl.value; } } function Password(parent, open) { let passInputEl = document.createElement('input'); parent.append(passInputEl); let passVisibilityCheckboxEl = document.createElement('input'); passVisibilityCheckboxEl.type = 'checkbox'; passVisibilityCheckboxEl.checked = open; parent.append(passVisibilityCheckboxEl); if (open) { passInputEl.type = 'text'; } else { passInputEl.type = 'password'; } passVisibilityCheckboxEl.addEventListener('change', (event) => { if (event.currentTarget.checked) { passInputEl.type = 'text'; } else { passInputEl.type = 'password'; } if (this.onOpenChange) { this.onOpenChange(event.currentTarget.checked); } }); passInputEl.addEventListener('input', (event) => { if (this.onChange) { this.onChange(); } }); this.setValue = (value) => { passInputEl.value = value; } this.getValue = () => { return passInputEl.value; } this.setOpen = (value) => { passVisibilityCheckboxEl.checked = value; } this.getOpen = () => { return passVisibilityCheckboxEl.checked; } } function drawLoginForm() { const form = new LoginForm(main); form.onSubmitForm = () => store.dispatch(actionFullLogin(form.getLoginValue(), form.getPasswordValue())); } store.subscribe(() => { const [, route] = location.hash.split('/'); if (route !== 'login') return; let status, payload, error; if (store.getState().promise?.login) { ({ status, payload, error } = store.getState().promise?.login); } if (status === 'FULFILLED') { if (payload) { location.hash = ''; main.innerHTML = ''; } else { let errorMessageEl = document.createElement('div'); main.append(errorMessageEl); errorMessageEl.innerText = 'You entered wrong login or password'; } } }) store.subscribe(() => { // header update userName.innerText = 'Hello, ' + (store.getState().auth.payload?.sub?.login || "anon"); login.hidden = store.getState().auth.token; registration.hidden = store.getState().auth.token; logout.hidden = !(store.getState().auth.token); historyPage.hidden = !(store.getState().auth.token); }); function drawRegisterForm() { const form = new LoginForm(main); form.onSubmitForm = () => store.dispatch(actionFullRegister(form.getLoginValue(), form.getPasswordValue())); } const drawGood = () => { const [, route] = location.hash.split('/'); if (route !== 'good') return; let status, payload, error; if (store.getState().promise.good) { ({ status, payload, error } = store.getState().promise.good); } if (status === 'PENDING') { main.innerHTML = ``; } if (status === "FULFILLED") { main.innerHTML = ''; const { name, images, categories, price, description, _id } = payload; drawTitle(name); if (categories?.length > 0) { drawCategoriesSection(categories, 'Category: '); } let goodSection = document.createElement('section'); main.append(goodSection); goodSection.classList.add('good-section'); let imagesContainer = document.createElement('div'); goodSection.append(imagesContainer); if (images.length > 0) { for (const image of images) { drawImage(imagesContainer, image.url); } } let goodInfo = document.createElement('div'); goodSection.append(goodInfo); goodInfo.classList.add('good-info'); if (description) { let descriptionEl = document.createElement('div'); goodInfo.append(descriptionEl); descriptionEl.innerText = description; descriptionEl.classList.add('description'); } if (price) { drawSum(goodInfo, `${price} UAH`); } let goodCountEl = drawCounter(goodInfo, 1); let buttonsContainer = document.createElement('div'); goodInfo.append(buttonsContainer); buttonsContainer.classList.add('buttons-container'); let cartAddButton = drawAddButton(buttonsContainer); let cartDelButton = drawDelButton(buttonsContainer); cartAddButton.addEventListener('click', event => { store.dispatch(actionCartAdd(payload, goodCountEl.value)); }); cartDelButton.addEventListener('click', event => { store.dispatch(actionCartDel(payload)); }); } } store.subscribe(() => drawGood()); function drawGoodsOrderContainer(parent, orderNameValue) { let goodsOrderContainer = document.createElement('div'); parent.append(goodsOrderContainer); goodsOrderContainer.classList.add('order-container'); let orderName = document.createElement('div'); goodsOrderContainer.append(orderName); orderName.innerText = orderNameValue; return goodsOrderContainer; } function drawTotalAmount(parent) { let totalAmountEl = document.createElement('div'); parent.append(totalAmountEl); totalAmountEl.classList.add('price'); totalAmountEl.classList.add('right-aligment'); return totalAmountEl; } const drawOrderHistory = () => { const [, route] = location.hash.split('/'); if (route !== 'history') return; let status, payload, error; if (store.getState().promise.history) { ({ status, payload, error } = store.getState().promise.history); } if (status === 'PENDING') { main.innerHTML = ``; } if (status === 'FULFILLED') { main.innerHTML = ''; drawTitle('My orders'); payload.sort((a, b) => b.createdAt - a.createdAt); payload.forEach(order => { let finalSum = 0; const { orderGoods, _id, total, createdAt } = order; let orderCreatedAt = new Date(+createdAt); let year = orderCreatedAt.getFullYear(); let month = orderCreatedAt.getMonth() < 9 ? '0' + (orderCreatedAt.getMonth() + 1) : orderCreatedAt.getMonth() + 1; let day = orderCreatedAt.getDate() < 9 ? '0' + (orderCreatedAt.getMonth() + 1) : orderCreatedAt.getMonth() + 1; let hours = orderCreatedAt.getHours() < 10 ? '0' + orderCreatedAt.getHours() : orderCreatedAt.getHours(); let minutes = orderCreatedAt.getMinutes() < 10 ? '0' + orderCreatedAt.getMinutes() : orderCreatedAt.getMinutes(); if (orderGoods?.length > 0) { let orderContainer = drawGoodsOrderContainer(main, `Order: ${_id}`); for (orderGood of orderGoods) { if (orderGood?.good?.name && orderGood?.count && orderGood?.price) { let orderGoodName = document.createElement('div'); orderContainer.append(orderGoodName); orderGoodName.innerText = `Good: ${orderGood?.good?.name}`; let goodCount = document.createElement('div'); orderContainer.append(goodCount); goodCount.innerText = `Count: ${orderGood?.count}`; drawSum(orderContainer, `${orderGood?.price} UAH`); } if (orderGood?.count && orderGood?.price) { finalSum += orderGood?.count * orderGood?.price; } } let totalAmountEl = drawTotalAmount(orderContainer); if (total > 0) { totalAmountEl.innerText += `Total amount: ${total} UAH`; } else { totalAmountEl.innerText += `Total amount: ${finalSum} UAH`; } let orderCreatedAtEl = document.createElement('div'); orderContainer.append(orderCreatedAtEl); orderCreatedAtEl.style.fontSize = '14px'; orderCreatedAtEl.classList.add('right-aligment'); orderCreatedAtEl.innerText = `Order created at: ${hours}:${minutes} ${day}.${month}.${year}`; } }); } } store.subscribe(() => drawOrderHistory()); const drawCartPage = () => { const [, route] = location.hash.split('/'); if (route !== 'cart') return; if (!(Object.values(store.getState().cart).length > 0)) { main.innerHTML = '
Cart is empty
'; } else { main.innerHTML = ''; let totalAmount = 0; let orderArray = []; drawTitle('Cart'); for (const goodOnCart of Object.values(store.getState().cart)) { const { good, count } = goodOnCart; let goodContainer = drawGoodsOrderContainer(main, `${good.name} (${good._id})`); drawSum(goodContainer, `${good.price} UAH`); let countContainer = document.createElement('div'); goodContainer.append(countContainer); countContainer.classList.add('count-container'); let countText = document.createElement('div'); countContainer.append(countText); countText.innerText = 'Count: '; let decreaseCountEl = document.createElement('button'); countContainer.append(decreaseCountEl); decreaseCountEl.classList.add('icon-container'); let decreaseImg = document.createElement('img'); decreaseCountEl.append(decreaseImg); decreaseImg.src = 'images/minus.png'; let goodCountEl = drawCounter(countContainer, count); let increaseCountEl = document.createElement('button'); countContainer.append(increaseCountEl); increaseCountEl.classList.add('icon-container'); let increaseImg = document.createElement('img'); increaseCountEl.append(increaseImg); increaseImg.src = 'images/plus.png'; //total good sum drawSum(goodContainer, `Total: ${count * good.price} UAH`); totalAmount += count * good.price; goodCountEl.addEventListener('change', e => { store.dispatch(actionCartSet(good, goodCountEl.value)); }) decreaseCountEl.addEventListener('click', e => { store.dispatch(actionCartSub(good)); }); increaseCountEl.addEventListener('click', e => { store.dispatch(actionCartAdd(good)); }) } orderArray = Object.values(store.getState().cart).map(value => { return { good: { _id: value.good._id }, count: value.count } }); let totalAmountEl = drawTotalAmount(main); totalAmountEl.innerText = `Total sum: ${totalAmount} UAH`; let createOrderButton = document.createElement('button'); main.append(createOrderButton); createOrderButton.classList.add('button'); createOrderButton.classList.add('order-button'); createOrderButton.innerText = 'Checkout'; let warningMessage = document.createElement('div'); main.append(warningMessage); warningMessage.classList.add('warning-message'); warningMessage.innerText = 'You need to login or register to place an order'; if (store.getState().auth.token) { createOrderButton.addEventListener('click', e => { store.dispatch(actionOrder(orderArray)); }); } else { createOrderButton.addEventListener('click', e => { warningMessage.style.display = 'block'; }); } } } let cartIcon = document.querySelector("img.cart-icon"); cartIcon.addEventListener('click', () => location.href = `#/cart`); store.subscribe(() => drawCartPage()); store.subscribe(() => { let sumTotal = 0; for (let { count } of Object.values(store.getState().cart)) { sumTotal += count; } cartIconEl.innerText = sumTotal; }); const gqlGetCategories = () => { const categoriesQuery = `query categories($q: String){ CategoryFind(query: $q){ _id name, goods{ name }, parent{ name }, image{ url }, subCategories{ name, subCategories{ name } } } }`; return gql(categoriesQuery, { q: "[{\"parent\": null}]" }); } const gqlGetCategory = (id) => { const categoryQuery = `query category($q: String) { CategoryFindOne(query: $q) { _id name, goods{ name, _id, images{ _id, url }, price }, parent { _id, name }, subCategories{ name, _id subCategories{ name, _id } } } }`; return gql(categoryQuery, { q: `[{"_id": "${id}"}]` }); } const gqlGetGood = (id) => { const goodQuery = `query good($q: String) { GoodFindOne(query: $q) { _id, name, categories{ _id, name }, description, price, images{ _id, url } } }`; return gql(goodQuery, { q: `[{"_id": "${id}"}]` }); } const gqlLogin = (login, password) => { const loginQuery = `query login($login:String, $password:String){ login(login:$login, password:$password) }`; return gql(loginQuery, { login, password }); } const gqlCreateUser = (login, password) => { const registrationQuery = `mutation registration($login:String, $password: String){ UserUpsert(user: {login:$login, password: $password}){ _id login createdAt } }`; return gql(registrationQuery, { login, password }); } const gqlGetOwnerOrders = () => { const ordersQuery = `query orders($q: String) { OrderFind(query: $q) { _id, total, createdAt, owner{ _id, login }, orderGoods{ price, count, good{ name } } } }`; return gql(ordersQuery, { q: `[{}]` }); } const gqlCreateOrder = (orderGoods) => { const orderQuery = `mutation ordering($orderGoods: OrderInput) { OrderUpsert(order: $orderGoods) { _id, total, orderGoods { good { name } } } }`; return gql(orderQuery, { orderGoods: { orderGoods } }); } const actionCategories = () => actionPromise('categories', gqlGetCategories()); const actionCategoryById = (id) => actionPromise('category', gqlGetCategory(id)); const actionGoodById = (id) => actionPromise('good', gqlGetGood(id)); const actionLogin = (login, password) => actionPromise('login', gqlLogin(login, password)); const actionCreateUser = (login, password) => actionPromise('register', gqlCreateUser(login, password)); const actionOwnerOrders = () => actionPromise('history', gqlGetOwnerOrders()); const actionCreateOrder = (orderGoods) => actionPromise('cart', gqlCreateOrder(orderGoods)); const actionFullLogin = (login, password) => async dispatch => { //dispatch возвращает то, что вернул thunk, возвращаемый actionLogin, а там промис, //так как actionPromise возвращает асинхронную функцию const token = await dispatch(actionLogin(login, password)) //проверьте что token - строка и отдайте его в actionAuthLogin console.log(token); if (typeof token === 'string') { dispatch(actionAuthLogin(token)); } } const actionFullRegister = (login, password) => async dispatch => { await dispatch(actionCreateUser(login, password)); dispatch(actionFullLogin(login, password)); } const actionOrder = (orderGoods) => async dispatch => { await dispatch(actionCreateOrder(orderGoods)); console.log('order was created'); dispatch(actionCartClear()); } store.dispatch(actionCategories()); store.subscribe(() => { const { status, payload, error } = store.getState().promise.categories; if (status === 'FULFILLED' && payload) { aside.innerHTML = ''; for (const { _id, name } of payload) { aside.innerHTML += `${name}`; } } }); store.subscribe(() => { const [, route] = location.hash.split('/'); if (!route) { main.innerHTML = ``; } }) logout.addEventListener('click', event => { store.dispatch(actionAuthLogout()); store.dispatch(actionCartClear()); }); window.onhashchange = () => { const [, route, _id] = location.hash.split('/'); const routes = { category() { store.dispatch(actionCategoryById(_id)); }, good() { store.dispatch(actionGoodById(_id)); console.log('good', _id); }, login() { console.log('А ТУТ ЩА ДОЛЖНА БЫТЬ ФОРМА ЛОГИНА'); drawLoginForm(); }, register() { drawRegisterForm(); }, history() { store.dispatch(actionOwnerOrders()); console.log('history page'); }, cart() { console.log('cart page'); drawCartPage(); } } if (route in routes) { routes[route](); } } window.onhashchange();