├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── babel.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── android-chrome-maskable-192x192.png │ │ ├── android-chrome-maskable-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html └── robots.txt ├── screenshot.jpg ├── src ├── App.vue ├── assets │ ├── logo.png │ └── styles │ │ ├── fonts.css │ │ └── tailwind.css ├── axios.ts ├── components │ ├── atoms │ │ ├── Alert.vue │ │ ├── BaseButton.vue │ │ ├── BaseInput.vue │ │ ├── BaseSelect.vue │ │ ├── BigButton.vue │ │ ├── Container.vue │ │ ├── Icon.vue │ │ └── Spinner.vue │ ├── molecules │ │ ├── AdminListItem.vue │ │ ├── CartItem.vue │ │ ├── Hero.vue │ │ ├── Modal.vue │ │ ├── MoreButton.vue │ │ ├── ProductCard.vue │ │ └── Toastie.vue │ ├── organisms │ │ ├── BaseFooter.vue │ │ ├── Drawer.vue │ │ ├── Navigation.vue │ │ └── ProductGrid.vue │ └── popups │ │ ├── LoginForm.vue │ │ ├── RegisterForm.vue │ │ └── UserPopup.vue ├── main.ts ├── model │ └── State.ts ├── registerServiceWorker.ts ├── router │ ├── index.ts │ └── router.d.ts ├── shims-vue.d.ts ├── shims-vuex.d.ts ├── store │ ├── actions.ts │ ├── getters.ts │ ├── index.ts │ └── mutations.ts └── views │ ├── About.vue │ ├── AddProduct.vue │ ├── AllProducts.vue │ ├── Cart.vue │ ├── Dashboard.vue │ ├── Error.vue │ ├── Favourites.vue │ ├── Home.vue │ ├── Product.vue │ ├── Profile.vue │ └── Settings.vue ├── tailwind.config.js ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/typescript/recommended", 10 | "@vue/prettier", 11 | "@vue/prettier/@typescript-eslint", 12 | "prettier", 13 | ], 14 | parserOptions: { 15 | ecmaVersion: 2020, 16 | }, 17 | rules: { 18 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 19 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 20 | "@typescript-eslint/camelcase": "off", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | .env 5 | 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shuuz 2 | 3 | shoe e-commerce website 4 | 5 | Shuuz homescreen 6 | 7 | ## Features 8 | 9 | - Own design 10 | - Vue3 + Vuex store 11 | - Strapi as a back-end 12 | - JWT Auth 13 | - Products can be added, edited and deleted by admin user. 14 | - Hero banner can be customized in admin dashboard. 15 | - Customer can store favourite items. 16 | - Cart shows all added items and sums up the total price 17 | 18 | ## Credits 19 | 20 | - [tailwind logo TailwindCSS](https://tailwindcss.com/) 21 | - [typescript logo TypeScript](https://www.typescriptlang.org/) 22 | - [vue logo Vue 3](https://v3.vuejs.org/) 23 | - [vue logo Vuex 4](https://vuex.vuejs.org/) 24 | - [strapi logo Strapi](https://strapi.io/) 25 | - [Eva-Icons](https://akveo.github.io/eva-icons/#/) 26 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"] 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "homepage": "http://bartektelec.github.io/shuuz-client", 3 | "name": "client", 4 | "version": "0.1.0", 5 | "private": true, 6 | "scripts": { 7 | "predeploy": "yarn build", 8 | "deploy": "gh-pages -d dist", 9 | "serve": "vue-cli-service serve", 10 | "build": "vue-cli-service build", 11 | "lint": "vue-cli-service lint" 12 | }, 13 | "dependencies": { 14 | "core-js": "^3.6.5", 15 | "eva-icons": "^1.1.3", 16 | "gh-pages": "^3.1.0", 17 | "register-service-worker": "^1.7.1", 18 | "vue": "^3.0.0", 19 | "vue-router": "^4.0.0-0", 20 | "vuex": "^4.0.0-0" 21 | }, 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^2.33.0", 24 | "@typescript-eslint/parser": "^2.33.0", 25 | "@vue/cli-plugin-babel": "~4.5.0", 26 | "@vue/cli-plugin-eslint": "~4.5.0", 27 | "@vue/cli-plugin-pwa": "~4.5.0", 28 | "@vue/cli-plugin-router": "~4.5.0", 29 | "@vue/cli-plugin-typescript": "~4.5.0", 30 | "@vue/cli-plugin-vuex": "~4.5.0", 31 | "@vue/cli-service": "~4.5.0", 32 | "@vue/compiler-sfc": "^3.0.0", 33 | "@vue/eslint-config-prettier": "^6.0.0", 34 | "@vue/eslint-config-typescript": "^5.0.2", 35 | "axios": "^0.18.0", 36 | "eslint": "^6.7.2", 37 | "eslint-plugin-prettier": "^3.1.3", 38 | "eslint-plugin-vue": "^7.0.0-0", 39 | "prettier": "^1.19.1", 40 | "sass": "^1.26.5", 41 | "sass-loader": "^8.0.2", 42 | "typescript": "~3.9.3", 43 | "vue-cli-plugin-axios": "~0.0.4", 44 | "vue-cli-plugin-tailwind": "~2.0.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | <%= htmlWebpackPlugin.options.title %> 14 | 15 | 16 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/screenshot.jpg -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 53 | 54 | 102 | 103 | 113 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartektelec/shuuz-client/9645f487d58acf44d36437ee8f34f4dd8c8a9b36/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Princess+Sofia&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Roboto:wght@700&display=swap"); 3 | @import url("https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;700&display=swap"); 4 | -------------------------------------------------------------------------------- /src/assets/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | axios.defaults.baseURL = process.env.VUE_APP_API_URL; 4 | -------------------------------------------------------------------------------- /src/components/atoms/Alert.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | 30 | 36 | 68 | -------------------------------------------------------------------------------- /src/components/atoms/BaseButton.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 37 | 38 | 52 | 53 | 107 | -------------------------------------------------------------------------------- /src/components/atoms/BaseInput.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 53 | 54 | 104 | -------------------------------------------------------------------------------- /src/components/atoms/BaseSelect.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 58 | 59 | 113 | -------------------------------------------------------------------------------- /src/components/atoms/BigButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /src/components/atoms/Container.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | -------------------------------------------------------------------------------- /src/components/atoms/Icon.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 72 | -------------------------------------------------------------------------------- /src/components/atoms/Spinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 48 | -------------------------------------------------------------------------------- /src/components/molecules/AdminListItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 48 | 49 | 58 | -------------------------------------------------------------------------------- /src/components/molecules/CartItem.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 56 | 57 | 84 | -------------------------------------------------------------------------------- /src/components/molecules/Hero.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 38 | 39 | 62 | 68 | -------------------------------------------------------------------------------- /src/components/molecules/Modal.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 31 | 32 | 66 | 67 | 72 | -------------------------------------------------------------------------------- /src/components/molecules/MoreButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/molecules/ProductCard.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 85 | 86 | 100 | 101 | 117 | -------------------------------------------------------------------------------- /src/components/molecules/Toastie.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 62 | 63 | 113 | -------------------------------------------------------------------------------- /src/components/organisms/BaseFooter.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | // 27 | 44 | 45 | 53 | 69 | -------------------------------------------------------------------------------- /src/components/organisms/Drawer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 44 | 45 | 88 | -------------------------------------------------------------------------------- /src/components/organisms/Navigation.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 96 | 97 | 137 | -------------------------------------------------------------------------------- /src/components/organisms/ProductGrid.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | 46 | 55 | -------------------------------------------------------------------------------- /src/components/popups/LoginForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 59 | 60 | 65 | -------------------------------------------------------------------------------- /src/components/popups/RegisterForm.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 139 | 140 | 145 | -------------------------------------------------------------------------------- /src/components/popups/UserPopup.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 84 | 85 | 99 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import "./axios"; 3 | import App from "./App.vue"; 4 | import "./registerServiceWorker"; 5 | import router from "./router"; 6 | import { store } from "./store"; 7 | import "./assets/styles/tailwind.css"; 8 | import "./assets/styles/fonts.css"; 9 | 10 | import Spinner from "@/components/atoms/Spinner.vue"; 11 | import Alert from "@/components/atoms/Alert.vue"; 12 | import BaseSelect from "@/components/atoms/BaseSelect.vue"; 13 | import BaseButton from "@/components/atoms/BaseButton.vue"; 14 | import BaseInput from "@/components/atoms/BaseInput.vue"; 15 | import Container from "@/components/atoms/Container.vue"; 16 | import Icon from "@/components/atoms/Icon.vue"; 17 | 18 | import Modal from "@/components/molecules/Modal.vue"; 19 | import Toastie from "@/components/molecules/Toastie.vue"; 20 | 21 | createApp(App) 22 | .component("Spinner", Spinner) 23 | .component("Toastie", Toastie) 24 | .component("Modal", Modal) 25 | .component("Alert", Alert) 26 | .component("BaseSelect", BaseSelect) 27 | .component("BaseButton", BaseButton) 28 | .component("BaseInput", BaseInput) 29 | .component("Container", Container) 30 | .component("Icon", Icon) 31 | .use(store) 32 | .use(router) 33 | .mount("#app"); 34 | -------------------------------------------------------------------------------- /src/model/State.ts: -------------------------------------------------------------------------------- 1 | export interface ImageFormat { 2 | ext: string; 3 | hash: string; 4 | height: number; 5 | mime: string; 6 | name: string; 7 | path: string | null; 8 | size: number; 9 | url: string; 10 | width: number; 11 | } 12 | 13 | export interface Image { 14 | alternativeText: string; 15 | caption: string; 16 | created_at: string; 17 | ext: string; 18 | formats: { 19 | [key: string]: ImageFormat; 20 | }; 21 | hash: string; 22 | height: number; 23 | id: number; 24 | mime: string; 25 | name: string; 26 | previewUrl: null; 27 | provider: string; 28 | provider_metadata: null; 29 | size: number; 30 | updated_at: "2020-11-05T10:59:26.282Z"; 31 | url: string; 32 | width: number; 33 | } 34 | 35 | export interface Product { 36 | brand: string; 37 | category: string; 38 | created_at: Date; 39 | description: string; 40 | featured: boolean; 41 | id: number; 42 | image: Image; 43 | image_url: string | null; 44 | price: number; 45 | published_at: string; 46 | rating: number; 47 | title: string; 48 | updated_at: string; 49 | } 50 | 51 | export interface Hero { 52 | created_at: string; 53 | hero_banner: Image; 54 | hero_btn_text: string; 55 | hero_route: string; 56 | hero_text: string; 57 | hero_title: string; 58 | hero_image_url: string; 59 | id: number; 60 | published_at: string; 61 | updated_at: string; 62 | } 63 | 64 | export interface CartItem { 65 | id: number; 66 | amount: number; 67 | } 68 | 69 | export interface User { 70 | blocked: boolean; 71 | confirmed: boolean; 72 | created_at: string; 73 | email: string; 74 | id: number; 75 | provider: string; 76 | role: { 77 | id: number; 78 | name: string; 79 | description: string; 80 | type: string; 81 | }; 82 | updated_at: string; 83 | username: string; 84 | } 85 | -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from "register-service-worker"; 4 | 5 | if (process.env.NODE_ENV === "production") { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | "App is being served from cache by a service worker.\n" + 10 | "For more details, visit https://goo.gl/AFskqB" 11 | ); 12 | }, 13 | registered() { 14 | console.log("Service worker has been registered."); 15 | }, 16 | cached() { 17 | console.log("Content has been cached for offline use."); 18 | }, 19 | updatefound() { 20 | console.log("New content is downloading."); 21 | }, 22 | updated() { 23 | console.log("New content is available; please refresh."); 24 | }, 25 | offline() { 26 | console.log( 27 | "No internet connection found. App is running in offline mode." 28 | ); 29 | }, 30 | error(error) { 31 | console.error("Error during service worker registration:", error); 32 | } 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; 2 | import { store } from "../store/index"; 3 | import Home from "../views/Home.vue"; 4 | 5 | const routes: Array = [ 6 | { 7 | path: "/", 8 | name: "Home", 9 | component: Home 10 | }, 11 | { 12 | path: "/about", 13 | name: "About", 14 | // route level code-splitting 15 | // this generates a separate chunk (about.[hash].js) for this route 16 | // which is lazy-loaded when the route is visited. 17 | component: () => 18 | import(/* webpackChunkName: "about" */ "../views/About.vue") 19 | }, 20 | { 21 | path: "/products/:category?", 22 | name: "Products", 23 | component: () => 24 | import(/* webpackChunkName: "allproducts" */ "../views/AllProducts.vue") 25 | }, 26 | { 27 | path: "/product/:id", 28 | name: "Product", 29 | component: () => 30 | import(/* webpackChunkName: "allproducts" */ "../views/Product.vue") 31 | }, 32 | { 33 | path: "/dashboard/:page?/:id?", 34 | name: "Dashboard", 35 | meta: { 36 | requiresAdmin: true 37 | }, 38 | component: () => 39 | import(/* webpackChunkName: "dashboard" */ "../views/Dashboard.vue") 40 | }, 41 | { 42 | path: "/cart", 43 | name: "Cart", 44 | component: () => 45 | import(/* webpackChunkName: "dashboard" */ "../views/Cart.vue") 46 | }, 47 | { 48 | path: "/favourites", 49 | name: "Favourites", 50 | component: () => 51 | import(/* webpackChunkName: "dashboard" */ "../views/Favourites.vue") 52 | }, 53 | { 54 | path: "/:pathMatch(.*)*", 55 | name: "Error", 56 | component: () => 57 | import(/* webpackChunkName: "dashboard" */ "../views/Error.vue") 58 | } 59 | ]; 60 | 61 | const router = createRouter({ 62 | history: createWebHashHistory(), 63 | routes 64 | }); 65 | 66 | router.beforeEach(async (to, from, next) => { 67 | const reqAuth = to.matched.some(record => record.meta.requiresAuth); 68 | const reqAdmin = to.matched.some(record => record.meta.requiresAdmin); 69 | 70 | if (!reqAuth && !reqAdmin) return next(); 71 | 72 | if (!store.getters.getJWT) { 73 | store.commit("addToast", { 74 | type: "error", 75 | message: "Auth error: User not logged in" 76 | }); 77 | 78 | return next(false); 79 | } 80 | if (reqAdmin) { 81 | try { 82 | const userData = await store.getters.getMe; 83 | 84 | const roleId = await userData.role.id; 85 | if (roleId !== 1) throw new Error("User is not admin"); 86 | return next(); 87 | } catch (error) { 88 | store.commit("addToast", { 89 | type: "error", 90 | message: "Auth error: " + error.message 91 | }); 92 | next(false); 93 | } 94 | } 95 | if (reqAuth) { 96 | try { 97 | const userData = await store.getters.getMe; 98 | 99 | const isConfirmed = await userData.confirmed; 100 | const isBlocked = await userData.blocked; 101 | if (!userData) throw new Error("User not logged in"); 102 | if (!isConfirmed) throw new Error("User not confirmed"); 103 | if (isBlocked) throw new Error("User is banned"); 104 | next(); 105 | } catch (error) { 106 | store.commit("addToast", { 107 | type: "error", 108 | message: "Auth error: " + error.message 109 | }); 110 | next(false); 111 | } 112 | } 113 | }); 114 | 115 | export default router; 116 | -------------------------------------------------------------------------------- /src/router/router.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare module "vue-router" { 4 | interface RouteMeta { 5 | requiresAdmin?: boolean; 6 | requiresAuth?: boolean; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue' 3 | const component: DefineComponent<{}, {}, any> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /src/shims-vuex.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "vuex"; 2 | import { State } from "./store"; 3 | 4 | declare module "@vue/runtime-core" { 5 | interface ComponentCustomProperties { 6 | $store: Store; 7 | } 8 | } 9 | 10 | declare module "vuex" { 11 | export function useStore(key?: string): Store; 12 | } 13 | -------------------------------------------------------------------------------- /src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree } from "vuex"; 2 | import axios from "axios"; 3 | import { State } from "./index"; 4 | import router from "../router/index"; 5 | 6 | const actions: ActionTree = { 7 | async onInit(context) { 8 | context.commit("setCachedJWTToken"); 9 | context.dispatch("fetchHeroData"); 10 | context.dispatch("fetchAllProducts"); 11 | context.commit("setCachedCart"); 12 | context.commit("getCachedFavourites"); 13 | }, 14 | 15 | async fetchAllProducts(context) { 16 | try { 17 | const payload = await axios.get("/products"); 18 | context.commit("setProducts", payload.data); 19 | } catch (error) { 20 | context.commit("addToast", { 21 | type: "error", 22 | message: 23 | error.name + ": Unable to fetch the products list, try again later" 24 | }); 25 | console.error(error.message); 26 | } 27 | }, 28 | 29 | async fetchHeroData(context) { 30 | try { 31 | const payload = await axios.get("/home"); 32 | context.commit("setHero", payload.data); 33 | } catch (error) { 34 | console.error(error.message); 35 | } 36 | }, 37 | 38 | async setHeroData(context, payload) { 39 | try { 40 | await axios.put("/home", payload, { 41 | headers: { 42 | Authorization: "Bearer " + context.state.jwt 43 | } 44 | }); 45 | context.commit("setHero", payload); 46 | context.commit("addToast", { 47 | message: "Hero updated successfully" 48 | }); 49 | } catch (error) { 50 | context.commit("addToast", { 51 | type: "error", 52 | message: "Couldn't update hero information" 53 | }); 54 | console.error(error.message); 55 | } 56 | }, 57 | 58 | async addProduct(context, payload) { 59 | try { 60 | await axios.post("/products", payload, { 61 | headers: { 62 | Authorization: "Bearer " + context.state.jwt 63 | } 64 | }); 65 | await context.dispatch("fetchAllProducts"); 66 | context.commit("addToast", { 67 | message: "Product has been added" 68 | }); 69 | } catch (error) { 70 | context.commit("addToast", { 71 | type: "error", 72 | message: "Couldn't add new product" 73 | }); 74 | console.error(error.message); 75 | } 76 | }, 77 | async updateProduct(context, payload) { 78 | try { 79 | const { id, ...newProduct } = payload; 80 | console.log(`id: ${id}`); 81 | console.log(`newProduct: ${newProduct}`); 82 | await axios.put("/products/" + id, newProduct, { 83 | headers: { 84 | Authorization: "Bearer " + context.state.jwt 85 | } 86 | }); 87 | await context.dispatch("fetchAllProducts"); 88 | context.commit("addToast", { 89 | message: "Product has been updated" 90 | }); 91 | } catch (error) { 92 | context.commit("addToast", { 93 | type: "error", 94 | message: "Couldn't update product" 95 | }); 96 | console.error(error.message); 97 | } 98 | }, 99 | async deleteProduct(context, payload) { 100 | try { 101 | await axios.delete("/products/" + payload, { 102 | headers: { 103 | Authorization: "Bearer " + context.state.jwt 104 | } 105 | }); 106 | await context.dispatch("fetchAllProducts"); 107 | context.commit("addToast", { 108 | message: "Product has been deleted" 109 | }); 110 | } catch (error) { 111 | context.commit("addToast", { 112 | type: "error", 113 | message: "Couldn't delete product" 114 | }); 115 | console.error(error.message); 116 | } 117 | }, 118 | 119 | addFavourite(context, payload) { 120 | if (context.state.favourites.includes(payload)) { 121 | return context.commit("removeFavourite", payload); 122 | } 123 | 124 | context.commit("addFavourite", payload); 125 | context.commit("addToast", { message: "Product added to favourites" }); 126 | }, 127 | addToCart(context, payload) { 128 | if (context.state.cart.some(item => item.id === payload)) 129 | return context.commit("addToast", { 130 | type: "error", 131 | message: "Product already in cart" 132 | }); 133 | 134 | context.commit("addToCart", payload); 135 | context.commit("addToast", { message: "Product added to cart" }); 136 | }, 137 | removeFromCart(context, payload) { 138 | if (context.state.cart.every(item => item.id !== payload)) return; 139 | 140 | context.commit("removeFromCart", payload); 141 | context.commit("addToast", { message: "Product removed from cart" }); 142 | }, 143 | async authorize(context, payload) { 144 | try { 145 | const user = await axios.post("/auth/local", payload); 146 | if (!user.data.user || !user.data.jwt) return; 147 | context.commit("setJWTToken", user.data.jwt); 148 | context.commit("addToast", { 149 | message: "Successfully logged in as " + user.data.user.username 150 | }); 151 | context.commit("setModal", null); 152 | } catch (error) { 153 | context.commit("addToast", { 154 | type: "error", 155 | message: error.name + ": Unable to authorize user." 156 | }); 157 | console.error(error.message); 158 | } 159 | }, 160 | 161 | async register(context, payload) { 162 | try { 163 | console.log(payload); 164 | await axios.post("/auth/local/register", payload); 165 | 166 | context.commit("addToast", { 167 | message: "Registered successfully" 168 | }); 169 | await context.dispatch("authorize", { 170 | identifier: payload.username, 171 | password: payload.password 172 | }); 173 | context.commit("setModal", null); 174 | } catch (error) { 175 | context.commit("addToast", { 176 | type: "error", 177 | message: error.name + ":" + error.message 178 | }); 179 | console.error(error.message); 180 | } 181 | }, 182 | 183 | logout(context) { 184 | context.commit("clearJWTToken", ""); 185 | context.commit("addToast", { message: "Logged out successfully" }); 186 | router.push("/"); 187 | } 188 | }; 189 | 190 | export default actions; 191 | -------------------------------------------------------------------------------- /src/store/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from "vuex"; 2 | import { State } from "./index"; 3 | import { lsKeys } from "./mutations"; 4 | import { Product } from "../model/State"; 5 | import axios from "axios"; 6 | 7 | const getters: GetterTree = { 8 | getAllProducts(state) { 9 | return state.products; 10 | }, 11 | getFeaturedProducts(state) { 12 | return state.products.filter(product => product.featured === true); 13 | }, 14 | 15 | getProductById: state => (id: string | number) => { 16 | const isNumber = (id: string | number): id is number => 17 | typeof id === "number"; 18 | if (!isNumber(id)) { 19 | id = parseInt(id); 20 | } 21 | const foundProduct = 22 | state.products.find((item: Product) => item.id === id) || null; 23 | return foundProduct; 24 | }, 25 | getAllFavourites(state) { 26 | return state.favourites 27 | .map(productId => { 28 | const foundProduct = state.products.find(item => item.id === productId); 29 | if (!foundProduct) return; 30 | return foundProduct; 31 | }) 32 | .filter(item => item !== undefined); 33 | }, 34 | getRandomProductName(state) { 35 | const rand = Math.floor(Math.random() * state.products.length); 36 | const randProduct = state.products[rand]; 37 | return `${randProduct.brand} ${randProduct.title}`; 38 | }, 39 | 40 | getJWT(state) { 41 | const cachedJWT = localStorage.getItem(lsKeys.jwt); 42 | if (!state.jwt || !cachedJWT) return null; 43 | 44 | return state.jwt || cachedJWT; 45 | }, 46 | 47 | async getMe(state, getters) { 48 | if (!getters.getJWT) return null; 49 | 50 | const userData = await axios.get("/users/me", { 51 | headers: { 52 | Authorization: "Bearer " + getters.getJWT 53 | } 54 | }); 55 | 56 | return userData.data; 57 | } 58 | }; 59 | 60 | export default getters; 61 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "vuex"; 2 | import { Product, Hero, CartItem } from "../model/State"; 3 | import mutations from "./mutations"; 4 | import actions from "./actions"; 5 | import getters from "./getters"; 6 | 7 | export const store = createStore({ 8 | state: { 9 | products: [], 10 | cart: [], 11 | jwt: "", 12 | toasts: [], 13 | favourites: [], 14 | hero: null, 15 | modal: null 16 | }, 17 | mutations: mutations, 18 | actions: actions, 19 | getters: getters, 20 | modules: {} 21 | }); 22 | 23 | interface ErrorItem { 24 | type: "primary" | "error"; 25 | icon: string; 26 | message: string; 27 | } 28 | 29 | export type State = { 30 | products: Product[]; 31 | cart: CartItem[]; 32 | jwt: string; 33 | toasts: ErrorItem[]; 34 | favourites: number[]; 35 | hero: Hero | null; 36 | modal: string | null; 37 | }; 38 | -------------------------------------------------------------------------------- /src/store/mutations.ts: -------------------------------------------------------------------------------- 1 | import { MutationTree } from "vuex"; 2 | import { State } from "./index"; 3 | 4 | export const lsKeys = { 5 | favourites: "shuuz-fav", 6 | jwt: "shuuz-token", 7 | cart: "shuuz-basket" 8 | }; 9 | 10 | const MAX_AMOUNT_TOASTS = 3; 11 | 12 | const mutations: MutationTree = { 13 | setProducts(state, payload) { 14 | state.products = payload; 15 | }, 16 | addToCart(state, payload) { 17 | if (state.cart.some(item => item.id === payload)) return; 18 | const cartItem = { amount: 1, id: payload }; 19 | state.cart.push(cartItem); 20 | localStorage.setItem(lsKeys.cart, JSON.stringify(state.cart)); 21 | }, 22 | 23 | removeFromCart(state, payload) { 24 | const indexToRemove = state.cart.findIndex(item => item.id === payload); 25 | if (indexToRemove < 0) return; 26 | state.cart.splice(indexToRemove, 1); 27 | localStorage.setItem(lsKeys.cart, JSON.stringify(state.cart)); 28 | }, 29 | setCachedCart(state) { 30 | const cachedCart = localStorage.getItem(lsKeys.cart); 31 | if (!cachedCart) return; 32 | state.cart = JSON.parse(cachedCart); 33 | }, 34 | addToast(state, payload) { 35 | if (state.toasts.length >= MAX_AMOUNT_TOASTS) state.toasts.splice(0, 1); 36 | state.toasts.push(payload); 37 | }, 38 | setHero(state, payload) { 39 | state.hero = payload; 40 | }, 41 | setModal(state, payload) { 42 | state.modal = payload; 43 | }, 44 | removeToast(state, payload) { 45 | if (payload >= state.toasts.length) return; 46 | 47 | state.toasts.splice(payload, 1); 48 | }, 49 | addFavourite(state, payload) { 50 | if (state.favourites.includes(payload)) return; 51 | state.favourites.push(payload); 52 | localStorage.setItem(lsKeys.favourites, JSON.stringify(state.favourites)); 53 | }, 54 | 55 | removeFavourite(state, payload) { 56 | const indexToRemove = state.favourites.findIndex(id => id === payload); 57 | if (indexToRemove < 0) return; 58 | state.favourites.splice(indexToRemove, 1); 59 | localStorage.setItem(lsKeys.favourites, JSON.stringify(state.favourites)); 60 | }, 61 | setCachedJWTToken(state) { 62 | const cachedToken = localStorage.getItem(lsKeys.jwt); 63 | if (!cachedToken) return; 64 | state.jwt = cachedToken; 65 | }, 66 | setJWTToken(state, payload) { 67 | state.jwt = payload; 68 | localStorage.setItem(lsKeys.jwt, state.jwt); 69 | }, 70 | clearJWTToken(state) { 71 | state.jwt = ""; 72 | localStorage.removeItem(lsKeys.jwt); 73 | }, 74 | getCachedFavourites(state) { 75 | const cachedFavourites = localStorage.getItem(lsKeys.favourites); 76 | if (!cachedFavourites) return; 77 | state.favourites = JSON.parse(cachedFavourites); 78 | } 79 | }; 80 | 81 | export default mutations; 82 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/views/AddProduct.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 185 | 186 | 227 | -------------------------------------------------------------------------------- /src/views/AllProducts.vue: -------------------------------------------------------------------------------- 1 | 96 | 97 | 204 | 205 | 227 | -------------------------------------------------------------------------------- /src/views/Cart.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 63 | 64 | 98 | 133 | -------------------------------------------------------------------------------- /src/views/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 56 | 57 | 83 | -------------------------------------------------------------------------------- /src/views/Error.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /src/views/Favourites.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 42 | 43 | 47 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/views/Product.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 122 | 123 | 192 | -------------------------------------------------------------------------------- /src/views/Profile.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: { content: ["./public/**/*.html", "./src/**/*.vue"] }, 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | fontFamily: { 6 | logo: ["Princess Sofia", "serif"], 7 | sans: ["Raleway", "sans-serif"], 8 | price: ["Roboto", "sans-serif"] 9 | }, 10 | extend: { 11 | borderRadius: { 12 | DEFAULT: ".25rem" 13 | }, 14 | container: { 15 | center: true, 16 | padding: "1rem" 17 | }, 18 | zIndex: { 19 | "-1": "-1", 20 | "-10": "-10" 21 | }, 22 | translate: { 23 | hide: "-200%" 24 | }, 25 | minHeight: { 26 | hero: "320px", 27 | summary: "calc(100vh - 15rem)" 28 | }, 29 | colors: { 30 | primary: { 31 | light: "#EBF4FF", 32 | DEFAULT: "#3D81F5", 33 | dark: "#1450B8", 34 | darker: "#1451B8" 35 | }, 36 | secondary: { 37 | light: "#FFFAF0", 38 | DEFAULT: "#F5AB3D", 39 | dark: "#C2620A" 40 | }, 41 | dark: { 42 | DEFAULT: "#333333", 43 | light: "#555555", 44 | lightest: "#777777" 45 | }, 46 | light: { 47 | dark: "#aaaaaa", 48 | DEFAULT: "#CCCCCC", 49 | light: "#fefefe" 50 | }, 51 | background: { 52 | dark: "#4A5568", 53 | light: "#F7FAFC" 54 | } 55 | } 56 | } 57 | }, 58 | variants: { 59 | extend: {} 60 | }, 61 | plugins: [] 62 | }; 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env" 16 | ], 17 | "paths": { 18 | "@/*": [ 19 | "src/*" 20 | ] 21 | }, 22 | "lib": [ 23 | "esnext", 24 | "dom", 25 | "dom.iterable", 26 | "scripthost" 27 | ] 28 | }, 29 | "include": [ 30 | "src/**/*.ts", 31 | "src/**/*.tsx", 32 | "src/**/*.vue", 33 | "tests/**/*.ts", 34 | "tests/**/*.tsx" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | // vue.config.js 2 | module.exports = { 3 | chainWebpack: config => { 4 | config.plugin('html').tap(args => { 5 | args[0].title = 'Shuuz'; 6 | return args; 7 | }); 8 | }, 9 | publicPath: process.env.NODE_ENV === 'production' ? '.' : '/', 10 | }; 11 | --------------------------------------------------------------------------------