├── .dockerignore ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── api └── api.js ├── config ├── passport.js └── secret.js ├── cypress.json ├── cypress └── integration │ ├── a │ ├── category_spec.js │ └── login_spec.js │ ├── b │ └── signup_spec.js │ └── c │ └── home.js ├── docker-compose.yml ├── etswana.yml ├── middleware.test.js ├── middleware ├── cpuUsage.js ├── middleware.js └── rateLimitter.js ├── models ├── cart.js ├── category.js ├── product.js └── user.js ├── nightwatch.json ├── package-lock.json ├── package.json ├── public ├── css │ ├── bootstrap.min.css │ └── theme.css ├── images │ ├── eTswana-Stores.png │ ├── favicon.ico │ ├── thumb.png │ ├── thumb1.png │ ├── thumb2.png │ └── thumb3.png └── js │ ├── bootstrap.min.js │ ├── custom.js │ ├── jquery.min.js │ └── spin.min.js ├── routes ├── admin.js ├── error.js ├── main.js └── user.js ├── server.js └── views ├── accounts ├── edit-profile.ejs ├── login.ejs ├── profile.ejs └── signup.ejs ├── admin └── add-category.ejs ├── layout.ejs ├── main ├── about.ejs ├── cart.ejs ├── category.ejs ├── error.ejs ├── home.ejs ├── product-main.ejs ├── product.ejs └── search-result.ejs └── partials ├── footer.ejs ├── javascriptonly.ejs └── navbar.ejs /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #cypress 2 | cypress/fixtures 3 | cypress/plugins 4 | cypress/support 5 | .DS_Store 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | .idea/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/.prettierignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/.prettierrc.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | # If you are building your code for production 13 | # RUN npm install --only=production 14 | 15 | # Bundle app source 16 | COPY . . 17 | 18 | EXPOSE 3000 19 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mr Modise 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Nodejs Ecommerce Store 2 | 3 | An amazon clone ecommerce store built in NodeJS utilizing Express, Stripe Payment, MongoDB, Elastic Search, Faker API just to mention a few. Developed with the latest cutting edge industry standard technologies, eTswana stores allows a shopper to browse through the online store and buy products using a credit card. eTswana stores supports social sign up and sign in using Facebook. 4 | 5 | ### Live Demo 6 | 7 | View the live demo as hosted on Heroku at 8 | https://etswana.herokuapp.com/ 9 | (application may take few seconds to open) 10 | 11 | ### Requirements 12 | 13 | ``` 14 | 1. Internet connection 15 | 2. NodeJS (https://nodejs.org/) 16 | 3. VS Code (optional: I use WebStorm) 17 | 4. MongoDB (you can use www.mlab.com or Docker alternative) 18 | 5. Elastic Search (https://www.elastic.co/ or Docker alternative): 19 | Download version 1.7.5 others are not compatable 20 | 6. Stripe account 21 | ``` 22 | 23 | ### Instructions 24 | 25 | Open the project in VS Code, and navigate to config folder. Open the config.js in the editor: 26 | 27 | Add mongodb URL: 28 | 29 | ``` 30 | database: '' e.g. mongodb://localhost:27017/test 31 | ``` 32 | 33 | Provide a secret key: 34 | 35 | ``` 36 | secretKey: "" - e.g. LKSJ&%$#XFE 37 | ``` 38 | 39 | Using Facebook developer site, create a Facebook app and retrieve the following: 40 | 41 | ``` 42 | clientID: process.env.FACEBOOK_ID || '' 43 | 44 | clientSecret: process.env.FACEBOOK_SECRET || '' 45 | ``` 46 | 47 | Create an account with Stripe Payment (https://stripe.com/) 48 | 49 | ``` 50 | Open routes/main.js to add the 'sk_test_SAF...' 51 | number retrieved from Stripe.com 52 | ``` 53 | 54 | ## Running the application 55 | 56 | Now that we are set. Open project in terminal, and: 57 | 58 | ``` 59 | npm install (this will install all dependencies) 60 | ``` 61 | 62 | Once all dependencies are installed 63 | 64 | ``` 65 | npm start 66 | ``` 67 | 68 | aggg!! Project does not run 69 | 70 | ``` 71 | * Make sure Elasticsearch is running (version 1.7.5). 72 | * If using MongoDB locally, make sure it is running as well. 73 | ``` 74 | 75 | alternatively if you have Docker and Docker compose installed then simply docker-compose up. This will install and run MongoD and ElasticSearch. 76 | 77 | ## Testing 78 | 79 | Add testing data to the application by loading API data from Faker API. To do this, 80 | open `http://localhost:3000/api/`. Do this for all categories. I will automate this functionality with Cypress UI automation tool. 81 | 82 | `started on automating this` 83 | 84 | To run automated UI tests, run `npm run test` 85 | 86 | The tests are written using the Cypress framework (https://www.cypress.io) 87 | 88 | `For Stripe payment testing please use the recommended cards on this: https://stripe.com/docs/testing` 89 | 90 | ## Docker & Kubernetes 91 | 92 | To create a Docker container for the application, `docker build -t etswana:1.0.0 .`. Create the Docker image with `docker run etswana:1.0.0` 93 | 94 | WIP: add more instructions on setting up Docker and Kubernetes including monitoring. 95 | 96 | ## Whats Next 97 | 98 | 1. ~~REFACTOR! Clean code comments~~ 99 | 2. ~~REFACTOR! Improved code readability~~ 100 | 3. More functionality to the ecommerce 101 | 4. Write a detailed tutorial - probably use a package to generate one 102 | from the code comments 103 | 104 | ### License 105 | 106 | ``` 107 | The MIT License (MIT) 108 | 109 | Copyright (c) 2016 Mr Modise 110 | 111 | Permission is hereby granted, free of charge, to any person obtaining a copy 112 | of this software and associated documentation files (the "Software"), to deal 113 | in the Software without restriction, including without limitation the rights 114 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 115 | copies of the Software, and to permit persons to whom the Software is 116 | furnished to do so, subject to the following conditions: 117 | 118 | The above copyright notice and this permission notice shall be included in all 119 | copies or substantial portions of the Software. 120 | 121 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 122 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 123 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 124 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 125 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 126 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 127 | SOFTWARE. 128 | ``` 129 | -------------------------------------------------------------------------------- /api/api.js: -------------------------------------------------------------------------------- 1 | // external imports 2 | const router = require("express").Router(); 3 | const async = require("async"); 4 | const faker = require("faker"); 5 | // custom imports 6 | const Category = require("../models/category"); 7 | const Product = require("../models/product"); 8 | 9 | /** 10 | * Handles POST HTTP search requests 11 | */ 12 | router.post("/search", (req, res, next) => { 13 | // search a product give the product name 14 | Product.search( 15 | { 16 | query_string: { query: req.body.search_term }, 17 | }, 18 | (err, results) => { 19 | // oops error might occur 20 | if (err) return next(err); 21 | // return search results in JSON format 22 | res.json(results); 23 | } 24 | ); 25 | }); 26 | 27 | /** 28 | * Handles GET HTTP requests to display products from Fake API 29 | */ 30 | router.get("/:name", (req, res, next) => { 31 | // executes array of functions in series, passing result of previous function to the next 32 | async.waterfall([ 33 | // function 1 34 | (callback) => { 35 | Category.findOne({ name: req.params.name }, (err, category) => { 36 | // oops error might occur 37 | if (err) return next(err); 38 | // return results to callback 39 | callback(null, category); 40 | }); 41 | }, 42 | // function 2 43 | (category) => { 44 | for (let i = 0; i < 30; i++) { 45 | // create a new product instance 46 | const product = new Product(); 47 | // set product properties using faker API 48 | product.category = category._id; 49 | product.name = faker.commerce.productName(); 50 | product.price = faker.commerce.price(); 51 | product.image = faker.image.image(); 52 | product.save(); 53 | } 54 | }, 55 | ]); 56 | // return success message in JSON format 57 | res.json({ message: "Success" }); 58 | }); 59 | 60 | module.exports = router; 61 | -------------------------------------------------------------------------------- /config/passport.js: -------------------------------------------------------------------------------- 1 | // needed for local authentication 2 | const passport = require("passport"); 3 | // needed for local login 4 | const LocalStrategy = require("passport-local").Strategy; 5 | // needed for facebook authentication 6 | const FacebookStrategy = require("passport-facebook").Strategy; 7 | const secret = require("../config/secret"); 8 | const User = require("../models/user"); 9 | const async = require("async"); 10 | const Cart = require("../models/cart"); 11 | 12 | // serialize and deserialize 13 | passport.serializeUser((user, done) => { 14 | done(null, user); 15 | }); 16 | 17 | passport.deserializeUser((id, done) => { 18 | User.findById(id, (err, user) => { 19 | done(err, user); 20 | }); 21 | }); 22 | 23 | // give the middleware a name, and create a new anonymous instance of LocalStrategy 24 | passport.use( 25 | "local-login", 26 | new LocalStrategy( 27 | { 28 | usernameField: "email", 29 | passwordField: "password", 30 | passReqToCallback: true, 31 | }, 32 | (req, email, password, done) => { 33 | // find a specific email 34 | User.findOne({ email: email }, (err, user) => { 35 | // incase of an error return a callback 36 | if (err) return done(err); 37 | 38 | if (!user) { 39 | return done( 40 | null, 41 | false, 42 | req.flash("loginMessage", "No user with such credentials found") 43 | ); 44 | } 45 | 46 | // compare user provided password and the database one 47 | if (!user.comparePassword(password)) { 48 | return done( 49 | null, 50 | false, 51 | req.flash("loginMessage", "Oops! Wrong credentials") 52 | ); 53 | } 54 | 55 | // return user object 56 | return done(null, user); 57 | }); 58 | } 59 | ) 60 | ); 61 | 62 | passport.use( 63 | new FacebookStrategy( 64 | secret.facebook, 65 | (token, refreshToken, profile, done) => { 66 | User.findOne({ facebook: profile.id }, (err, user) => { 67 | if (err) return next(err); 68 | 69 | if (user) { 70 | return done(null, user); 71 | } else { 72 | async.waterfall([ 73 | (callback) => { 74 | const newUser = new User(); 75 | newUser.email = profile._json.email; 76 | newUser.facebook = profile.id; 77 | newUser.tokens.push({ kind: "facebook", token: token }); 78 | newUser.profile.name = profile.displayName; 79 | newUser.profile.picture = 80 | "https://graph.facebook.com/" + 81 | profile.id + 82 | "/picture?type=large"; 83 | 84 | newUser.save((err) => { 85 | if (err) return next(err); 86 | callback(err, newUser._id); 87 | }); 88 | }, 89 | (newUser) => { 90 | const cart = new Cart(); 91 | 92 | cart.owner = newUser._id; 93 | cart.save((err) => { 94 | if (err) return done(err); 95 | return done(err, newUser); 96 | }); 97 | }, 98 | ]); 99 | } 100 | }); 101 | } 102 | ) 103 | ); 104 | 105 | // custom function validate 106 | exports.isAuthenticated = (req, res, next) => { 107 | if (req.isAuthenticated()) { 108 | return next(); 109 | } 110 | res.redirect("/login"); 111 | }; 112 | -------------------------------------------------------------------------------- /config/secret.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | database: "", 3 | port: process.env.PORT || 3000, 4 | secretKey: "", 5 | facebook: { 6 | clientID: process.env.FACEBOOK_ID || "", 7 | clientSecret: process.env.FACEBOOK_SECRET || "", 8 | profileFields: ["emails", "displayName"], 9 | callbackURL: "https://localhost/auth/facebook/callback", 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "a7bq2k", 3 | "baseUrl": "http://localhost:3000", 4 | "viewportWidth": 1280, 5 | "viewportHeight": 660 6 | } 7 | -------------------------------------------------------------------------------- /cypress/integration/a/category_spec.js: -------------------------------------------------------------------------------- 1 | context("Product Category", () => { 2 | // TODO: refactor these tests to avoid duplicates 3 | before(() => { 4 | cy.visit("/add-category"); 5 | }); 6 | 7 | specify("should add food category", () => { 8 | cy.get("#addCategoryForm").within(() => { 9 | cy.get("#category-name").type("food"); 10 | cy.root().submit(); 11 | }); 12 | cy.get("#cat-confirmation").should( 13 | "have.text", 14 | "Info! Successfully added a category" 15 | ); 16 | }); 17 | 18 | specify("should add clothes category", () => { 19 | cy.get("#addCategoryForm").within(() => { 20 | cy.get("#category-name").type("clothes"); 21 | cy.root().submit(); 22 | }); 23 | cy.get("#cat-confirmation").should( 24 | "have.text", 25 | "Info! Successfully added a category" 26 | ); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /cypress/integration/a/login_spec.js: -------------------------------------------------------------------------------- 1 | context("Logging users into the application", () => { 2 | specify("should fail to login user with incorrect credentials", () => { 3 | cy.visit("/login"); 4 | cy.get("#email").type("tester1@gmail.com"); 5 | cy.get("#password").type("password123"); 6 | cy.get("#submit").click(); 7 | cy.get("#error-login").should( 8 | "have.text", 9 | "Error! Oops! Wrong credentials" 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/integration/b/signup_spec.js: -------------------------------------------------------------------------------- 1 | const Chance = require("chance"); 2 | context("User Registration", () => { 3 | let chance; 4 | before(() => { 5 | chance = new Chance(); 6 | cy.visit("/signup"); 7 | }); 8 | 9 | specify("should register user given correct information", () => { 10 | cy.get("#signUpForm").within(() => { 11 | cy.get('input[name="name"]').type(chance.word()); 12 | cy.get('input[name="email"]').type(chance.email()); 13 | cy.get('input[name="password"]').type("password123"); 14 | cy.root().submit(); 15 | }); 16 | cy.get("#heading").should("have.text", "History"); 17 | }); 18 | 19 | specify("should not register an existing user", () => { 20 | cy.visit("/signup"); 21 | cy.get("#signUpForm").within(() => { 22 | cy.get('input[name="name"]').type("tester tester"); 23 | cy.get('input[name="email"]').type("tester1@gmail.com"); 24 | cy.get('input[name="password"]').type("tester1"); 25 | cy.root().submit(); 26 | }); 27 | cy.get("#error-signup").should( 28 | "have.text", 29 | "Error! Account with that email address already exists" 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /cypress/integration/c/home.js: -------------------------------------------------------------------------------- 1 | const chance = new Chance(); 2 | context("Navigate to search page", () => { 3 | let chance; 4 | before(() => { 5 | chance = new Chance(); 6 | cy.visit("/signup"); 7 | }); 8 | 9 | specify("should navigate to the home page once logged in", () => { 10 | cy.get("#navbar").click(); 11 | cy.get("#main-heading").should("have.text", "Shop Smart, Save Big!"); 12 | cy.get("#sub-heading").should( 13 | "have.text", 14 | "Starting point to create something more unique" 15 | ); 16 | }); 17 | 18 | specify("search for random products", () => { 19 | cy.get("#searchForm").within(() => { 20 | cy.get("#search-box").type(chance.word()); 21 | cy.root().submit(); 22 | }); 23 | 24 | cy.get("#no-products").should("have.text", "No products found"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | mongodb_container: 4 | image: mongo:latest 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: root 7 | MONGO_INITDB_ROOT_PASSWORD: root 8 | ports: 9 | - 27017:27017 10 | networks: 11 | - etswana-network 12 | volumes: 13 | - mongodb_data_container:/data/db 14 | 15 | elasticsearch: 16 | image: elasticsearch:1.7.5 17 | container_name: elasticsearch 18 | ports: 19 | - 9200:9200 20 | networks: 21 | - etswana-network 22 | 23 | volumes: 24 | mongodb_data_container: 25 | networks: 26 | etswana-network: 27 | driver: bridge 28 | -------------------------------------------------------------------------------- /etswana.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: etswana 5 | labels: 6 | name: etswana 7 | spec: 8 | containers: 9 | - name: etswana_web 10 | image: node:carbon 11 | args: 12 | - "-http=0.0.0.0:80" 13 | - "-health=0.0.0.0:81" 14 | - "-secret=secret" 15 | ports: 16 | - name: http 17 | containerPort: 80 18 | - name: health 19 | containerPort: 81 20 | resources: 21 | limits: 22 | cpu: 0.2 23 | memory: "10Mi" 24 | -------------------------------------------------------------------------------- /middleware.test.js: -------------------------------------------------------------------------------- 1 | const { cpu } = require("node-os-utils"); 2 | const rateLimit = require("./middleware/rateLimitter"); 3 | const cpuPercentage = require("./middleware/cpuusage.js"); 4 | test("CPU overuse test", () => { 5 | const percentage = cpuPercentage(); 6 | var current_percentage; 7 | cpu.usage().then((curr_value) => { 8 | current_percentage = curr_value; 9 | }); 10 | expect(percentage).toBe(current_percentage); 11 | }); 12 | -------------------------------------------------------------------------------- /middleware/cpuUsage.js: -------------------------------------------------------------------------------- 1 | const os = require("node-os-utils"); 2 | const cpu = os.cpu; 3 | 4 | function cpuPercentage() { 5 | cpu.usage().then((info) => { 6 | return "Server Overload, CPU Percentage : " + info; 7 | }); 8 | } 9 | module.exports = cpuPercentage; 10 | -------------------------------------------------------------------------------- /middleware/middleware.js: -------------------------------------------------------------------------------- 1 | const Cart = require("../models/cart"); 2 | 3 | module.exports = (req, res, next) => { 4 | if (req.user) { 5 | let total = 0; 6 | Cart.findOne({ owner: req.user._id }, (err, cart) => { 7 | if (cart) { 8 | for (let i = 0; i < cart.items.length; i++) { 9 | total += cart.items[i].quantity; 10 | } 11 | res.locals.cart = total; 12 | } else { 13 | res.locals.cart = 0; 14 | } 15 | next(); 16 | }); 17 | } else { 18 | next(); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /middleware/rateLimitter.js: -------------------------------------------------------------------------------- 1 | const rateLimit = require("express-rate-limit"); 2 | rateLimit({ 3 | windowMs: 12 * 60 * 60 * 1000, // Window time interval in which user can make requests 4 | max: 5000, //Maximum number of requests user can make in the given interval 5 | message: "Please slow down ! ", // message to be displayed to user after exhauting the limit 6 | headers: true, 7 | }); 8 | 9 | module.exports = rateLimit; 10 | -------------------------------------------------------------------------------- /models/cart.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const Schema = mongoose.Schema; 3 | 4 | const CartSchema = new Schema({ 5 | owner: { type: Schema.Types.ObjectId, ref: "User" }, 6 | total: { type: Number, default: 0 }, 7 | items: [ 8 | { 9 | item: { type: Schema.Types.ObjectId, ref: "Product" }, 10 | quantity: { type: Number, default: 1 }, 11 | price: { type: Number, default: 0 }, 12 | }, 13 | ], 14 | }); 15 | 16 | module.exports = mongoose.model("Cart", CartSchema); 17 | -------------------------------------------------------------------------------- /models/category.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const Schema = mongoose.Schema; 3 | 4 | const CategorySchema = new Schema({ 5 | name: { type: String, unique: true, lowercase: true }, 6 | }); 7 | 8 | module.exports = mongoose.model("Category", CategorySchema); 9 | -------------------------------------------------------------------------------- /models/product.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | require("mongoose-long")(mongoose); 3 | const mongoosastic = require("mongoosastic"); 4 | const Schema = mongoose.Schema; 5 | ObjectId = Schema.Types; 6 | 7 | const ProductSchema = new Schema({ 8 | category: { 9 | type: mongoose.Schema.Types.ObjectId, 10 | ref: "Category", 11 | }, 12 | name: String, 13 | price: String, 14 | image: String, 15 | }); 16 | 17 | ProductSchema.plugin(mongoosastic, { 18 | hosts: ["localhost:9200"], 19 | }); 20 | 21 | module.exports = mongoose.model("Product", ProductSchema); 22 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | // require the needed modules 2 | const mongoose = require("mongoose"); 3 | const bcrypt = require("bcryptjs"); 4 | const crypto = require("crypto"); 5 | const Schema = mongoose.Schema; 6 | 7 | /** With mongoose, everything is derived from the schema. 8 | We have a Schema below with email, password, profile, address, history properties */ 9 | const UserSchema = new Schema({ 10 | email: { type: String, unique: true, lowercase: true }, 11 | facebook: String, 12 | tokens: Array, 13 | password: String, 14 | profile: { 15 | name: { type: String, default: "" }, 16 | picture: { type: String, default: "" }, 17 | }, 18 | address: String, 19 | history: [ 20 | { 21 | paid: { type: Number, default: 0 }, 22 | item: { type: Schema.Types.ObjectId, ref: "Product" }, 23 | }, 24 | ], 25 | }); 26 | 27 | /** Hash the password before saving it to the database*/ 28 | UserSchema.pre("save", function (next) { 29 | /** this refers to the user passed as argument to the save method in /routes/user*/ 30 | const user = this; 31 | /** only hash the password if it has been modified or its new */ 32 | if (!user.isModified("password")) return next(); 33 | // generate the salt 34 | bcrypt.genSalt(10, (err, salt) => { 35 | /** hash the password using the generated salt */ 36 | bcrypt.hash(user.password, salt, (err, hash) => { 37 | /** if an error has occured we stop hashing */ 38 | if (err) return next(err); 39 | /** override the cleartext (user entered) passsword with the hashed one */ 40 | user.password = hash; 41 | /** return a callback */ 42 | next(); 43 | }); 44 | }); 45 | }); 46 | 47 | /** compare database password with user user entered password */ 48 | UserSchema.methods.comparePassword = (userPassword) => { 49 | /** this.password refers to the database password, 50 | userPassword to the password the user entered on the login form*/ 51 | return bcrypt.compareSync(userPassword, this.password); 52 | }; 53 | 54 | /** add avator incase user does not have a profile picture */ 55 | UserSchema.methods.gravatar = (size) => { 56 | if (!this.size) size = 200; 57 | if (!this.email) return "https://gravatar.com/avatar/?s" + size + "&d=retro"; 58 | const md5 = crypto.createHash("md5").update(this.email).digest("hex"); 59 | /** return avator to save to database*/ 60 | return "https://gravatar.com/avatar/" + md5 + "?s=" + size + "&d=retro"; 61 | }; 62 | /** compiling our schema into a model object - a class that constructs documents in mongoose */ 63 | module.exports = mongoose.model("User", UserSchema); 64 | -------------------------------------------------------------------------------- /nightwatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "src_folders": ["tests"], 3 | "output_folder": "reports", 4 | "custom_commands_path": "", 5 | "custom_assertions_path": "", 6 | "page_objects_path": "", 7 | "globals_path": "", 8 | 9 | "selenium": { 10 | "start_process": false, 11 | "server_path": "", 12 | "log_path": "", 13 | "port": 4444, 14 | "cli_args": { 15 | "webdriver.chrome.driver": "", 16 | "webdriver.gecko.driver": "", 17 | "webdriver.edge.driver": "" 18 | } 19 | }, 20 | 21 | "test_settings": { 22 | "default": { 23 | "launch_url": "http://localhost", 24 | "selenium_port": 4444, 25 | "selenium_host": "localhost", 26 | "silent": true, 27 | "screenshots": { 28 | "enabled": false, 29 | "path": "" 30 | }, 31 | "desiredCapabilities": { 32 | "browserName": "firefox", 33 | "marionette": true 34 | } 35 | }, 36 | 37 | "chrome": { 38 | "desiredCapabilities": { 39 | "browserName": "chrome" 40 | } 41 | }, 42 | 43 | "edge": { 44 | "desiredCapabilities": { 45 | "browserName": "MicrosoftEdge" 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecommerce", 3 | "version": "2.0.0", 4 | "description": "An e-commerce store", 5 | "scripts": { 6 | "start": "nodemon server.js", 7 | "server": "node server.js", 8 | "test": "npx cypress open", 9 | "test:record": "npx cypress run --record --key a7bq2k", 10 | "test-record": "npx cypress run --record --key mokwena@", 11 | "test-security": "jest" 12 | }, 13 | "author": "MP Modise", 14 | "license": "MIT", 15 | "dependencies": { 16 | "async": "3.2.2", 17 | "bcrypt-nodejs": "0.0.3", 18 | "bcryptjs": "2.3.0", 19 | "body-parser": "1.19.0", 20 | "connect-mongo": "3.2.0", 21 | "connect-mongodb-session": "2.3.1", 22 | "content-type": "^1.0.4", 23 | "cookie-parser": "1.4.4", 24 | "ejs": "3.1.7", 25 | "ejs-mate": "3.0.0", 26 | "elasticsearch": "16.7.3", 27 | "express": "4.17.3", 28 | "express-flash": "0.0.2", 29 | "express-rate-limit": "^5.3.0", 30 | "express-session": "1.16.1", 31 | "faker": "4.1.0", 32 | "helmet": "^4.6.0", 33 | "hpp": "^0.2.3", 34 | "jest": "^27.0.6", 35 | "mongoosastic": "4.5.1", 36 | "mongoose": "5.13.15", 37 | "mongoose-double": "0.0.1", 38 | "mongoose-long": "0.2.1", 39 | "morgan": "1.9.1", 40 | "node-os-utils": "^1.3.5", 41 | "passport": "0.6.0", 42 | "passport-facebook": "2.1.1", 43 | "passport-local": "1.0.0", 44 | "raw-body": "^2.4.1", 45 | "stripe": "5.4.0", 46 | "toobusy-js": "^0.5.1" 47 | }, 48 | "devDependencies": { 49 | "chance": "1.1.4", 50 | "cypress": "4.12.1", 51 | "nodemon": "2.0.20", 52 | "opn-cli": "5.0.0", 53 | "prettier": "2.3.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/images/eTswana-Stores.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/eTswana-Stores.png -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/favicon.ico -------------------------------------------------------------------------------- /public/images/thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/thumb.png -------------------------------------------------------------------------------- /public/images/thumb1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/thumb1.png -------------------------------------------------------------------------------- /public/images/thumb2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/thumb2.png -------------------------------------------------------------------------------- /public/images/thumb3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrmodise/nodejs-ecommerce-store/69e14afb740a561191a609aaf6eb9d71c10f0fd5/public/images/thumb3.png -------------------------------------------------------------------------------- /public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.1 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if ("undefined" == typeof jQuery) 7 | throw new Error("Bootstrap's JavaScript requires jQuery"); 8 | +(function (a) { 9 | var b = a.fn.jquery.split(" ")[0].split("."); 10 | if ((b[0] < 2 && b[1] < 9) || (1 == b[0] && 9 == b[1] && b[2] < 1)) 11 | throw new Error( 12 | "Bootstrap's JavaScript requires jQuery version 1.9.1 or higher" 13 | ); 14 | })(jQuery), 15 | +(function (a) { 16 | "use strict"; 17 | function b() { 18 | var a = document.createElement("bootstrap"), 19 | b = { 20 | WebkitTransition: "webkitTransitionEnd", 21 | MozTransition: "transitionend", 22 | OTransition: "oTransitionEnd otransitionend", 23 | transition: "transitionend", 24 | }; 25 | for (var c in b) if (void 0 !== a.style[c]) return { end: b[c] }; 26 | return !1; 27 | } 28 | (a.fn.emulateTransitionEnd = function (b) { 29 | var c = !1, 30 | d = this; 31 | a(this).one("bsTransitionEnd", function () { 32 | c = !0; 33 | }); 34 | var e = function () { 35 | c || a(d).trigger(a.support.transition.end); 36 | }; 37 | return setTimeout(e, b), this; 38 | }), 39 | a(function () { 40 | (a.support.transition = b()), 41 | a.support.transition && 42 | (a.event.special.bsTransitionEnd = { 43 | bindType: a.support.transition.end, 44 | delegateType: a.support.transition.end, 45 | handle: function (b) { 46 | return a(b.target).is(this) 47 | ? b.handleObj.handler.apply(this, arguments) 48 | : void 0; 49 | }, 50 | }); 51 | }); 52 | })(jQuery), 53 | +(function (a) { 54 | "use strict"; 55 | function b(b) { 56 | return this.each(function () { 57 | var c = a(this), 58 | e = c.data("bs.alert"); 59 | e || c.data("bs.alert", (e = new d(this))), 60 | "string" == typeof b && e[b].call(c); 61 | }); 62 | } 63 | var c = '[data-dismiss="alert"]', 64 | d = function (b) { 65 | a(b).on("click", c, this.close); 66 | }; 67 | (d.VERSION = "3.3.1"), 68 | (d.TRANSITION_DURATION = 150), 69 | (d.prototype.close = function (b) { 70 | function c() { 71 | g.detach().trigger("closed.bs.alert").remove(); 72 | } 73 | var e = a(this), 74 | f = e.attr("data-target"); 75 | f || ((f = e.attr("href")), (f = f && f.replace(/.*(?=#[^\s]*$)/, ""))); 76 | var g = a(f); 77 | b && b.preventDefault(), 78 | g.length || (g = e.closest(".alert")), 79 | g.trigger((b = a.Event("close.bs.alert"))), 80 | b.isDefaultPrevented() || 81 | (g.removeClass("in"), 82 | a.support.transition && g.hasClass("fade") 83 | ? g 84 | .one("bsTransitionEnd", c) 85 | .emulateTransitionEnd(d.TRANSITION_DURATION) 86 | : c()); 87 | }); 88 | var e = a.fn.alert; 89 | (a.fn.alert = b), 90 | (a.fn.alert.Constructor = d), 91 | (a.fn.alert.noConflict = function () { 92 | return (a.fn.alert = e), this; 93 | }), 94 | a(document).on("click.bs.alert.data-api", c, d.prototype.close); 95 | })(jQuery), 96 | +(function (a) { 97 | "use strict"; 98 | function b(b) { 99 | return this.each(function () { 100 | var d = a(this), 101 | e = d.data("bs.button"), 102 | f = "object" == typeof b && b; 103 | e || d.data("bs.button", (e = new c(this, f))), 104 | "toggle" == b ? e.toggle() : b && e.setState(b); 105 | }); 106 | } 107 | var c = function (b, d) { 108 | (this.$element = a(b)), 109 | (this.options = a.extend({}, c.DEFAULTS, d)), 110 | (this.isLoading = !1); 111 | }; 112 | (c.VERSION = "3.3.1"), 113 | (c.DEFAULTS = { loadingText: "loading..." }), 114 | (c.prototype.setState = function (b) { 115 | var c = "disabled", 116 | d = this.$element, 117 | e = d.is("input") ? "val" : "html", 118 | f = d.data(); 119 | (b += "Text"), 120 | null == f.resetText && d.data("resetText", d[e]()), 121 | setTimeout( 122 | a.proxy(function () { 123 | d[e](null == f[b] ? this.options[b] : f[b]), 124 | "loadingText" == b 125 | ? ((this.isLoading = !0), d.addClass(c).attr(c, c)) 126 | : this.isLoading && 127 | ((this.isLoading = !1), d.removeClass(c).removeAttr(c)); 128 | }, this), 129 | 0 130 | ); 131 | }), 132 | (c.prototype.toggle = function () { 133 | var a = !0, 134 | b = this.$element.closest('[data-toggle="buttons"]'); 135 | if (b.length) { 136 | var c = this.$element.find("input"); 137 | "radio" == c.prop("type") && 138 | (c.prop("checked") && this.$element.hasClass("active") 139 | ? (a = !1) 140 | : b.find(".active").removeClass("active")), 141 | a && 142 | c 143 | .prop("checked", !this.$element.hasClass("active")) 144 | .trigger("change"); 145 | } else 146 | this.$element.attr("aria-pressed", !this.$element.hasClass("active")); 147 | a && this.$element.toggleClass("active"); 148 | }); 149 | var d = a.fn.button; 150 | (a.fn.button = b), 151 | (a.fn.button.Constructor = c), 152 | (a.fn.button.noConflict = function () { 153 | return (a.fn.button = d), this; 154 | }), 155 | a(document) 156 | .on( 157 | "click.bs.button.data-api", 158 | '[data-toggle^="button"]', 159 | function (c) { 160 | var d = a(c.target); 161 | d.hasClass("btn") || (d = d.closest(".btn")), 162 | b.call(d, "toggle"), 163 | c.preventDefault(); 164 | } 165 | ) 166 | .on( 167 | "focus.bs.button.data-api blur.bs.button.data-api", 168 | '[data-toggle^="button"]', 169 | function (b) { 170 | a(b.target) 171 | .closest(".btn") 172 | .toggleClass("focus", /^focus(in)?$/.test(b.type)); 173 | } 174 | ); 175 | })(jQuery), 176 | +(function (a) { 177 | "use strict"; 178 | function b(b) { 179 | return this.each(function () { 180 | var d = a(this), 181 | e = d.data("bs.carousel"), 182 | f = a.extend({}, c.DEFAULTS, d.data(), "object" == typeof b && b), 183 | g = "string" == typeof b ? b : f.slide; 184 | e || d.data("bs.carousel", (e = new c(this, f))), 185 | "number" == typeof b 186 | ? e.to(b) 187 | : g 188 | ? e[g]() 189 | : f.interval && e.pause().cycle(); 190 | }); 191 | } 192 | var c = function (b, c) { 193 | (this.$element = a(b)), 194 | (this.$indicators = this.$element.find(".carousel-indicators")), 195 | (this.options = c), 196 | (this.paused = 197 | this.sliding = 198 | this.interval = 199 | this.$active = 200 | this.$items = 201 | null), 202 | this.options.keyboard && 203 | this.$element.on("keydown.bs.carousel", a.proxy(this.keydown, this)), 204 | "hover" == this.options.pause && 205 | !("ontouchstart" in document.documentElement) && 206 | this.$element 207 | .on("mouseenter.bs.carousel", a.proxy(this.pause, this)) 208 | .on("mouseleave.bs.carousel", a.proxy(this.cycle, this)); 209 | }; 210 | (c.VERSION = "3.3.1"), 211 | (c.TRANSITION_DURATION = 600), 212 | (c.DEFAULTS = { interval: 5e3, pause: "hover", wrap: !0, keyboard: !0 }), 213 | (c.prototype.keydown = function (a) { 214 | if (!/input|textarea/i.test(a.target.tagName)) { 215 | switch (a.which) { 216 | case 37: 217 | this.prev(); 218 | break; 219 | case 39: 220 | this.next(); 221 | break; 222 | default: 223 | return; 224 | } 225 | a.preventDefault(); 226 | } 227 | }), 228 | (c.prototype.cycle = function (b) { 229 | return ( 230 | b || (this.paused = !1), 231 | this.interval && clearInterval(this.interval), 232 | this.options.interval && 233 | !this.paused && 234 | (this.interval = setInterval( 235 | a.proxy(this.next, this), 236 | this.options.interval 237 | )), 238 | this 239 | ); 240 | }), 241 | (c.prototype.getItemIndex = function (a) { 242 | return ( 243 | (this.$items = a.parent().children(".item")), 244 | this.$items.index(a || this.$active) 245 | ); 246 | }), 247 | (c.prototype.getItemForDirection = function (a, b) { 248 | var c = "prev" == a ? -1 : 1, 249 | d = this.getItemIndex(b), 250 | e = (d + c) % this.$items.length; 251 | return this.$items.eq(e); 252 | }), 253 | (c.prototype.to = function (a) { 254 | var b = this, 255 | c = this.getItemIndex( 256 | (this.$active = this.$element.find(".item.active")) 257 | ); 258 | return a > this.$items.length - 1 || 0 > a 259 | ? void 0 260 | : this.sliding 261 | ? this.$element.one("slid.bs.carousel", function () { 262 | b.to(a); 263 | }) 264 | : c == a 265 | ? this.pause().cycle() 266 | : this.slide(a > c ? "next" : "prev", this.$items.eq(a)); 267 | }), 268 | (c.prototype.pause = function (b) { 269 | return ( 270 | b || (this.paused = !0), 271 | this.$element.find(".next, .prev").length && 272 | a.support.transition && 273 | (this.$element.trigger(a.support.transition.end), this.cycle(!0)), 274 | (this.interval = clearInterval(this.interval)), 275 | this 276 | ); 277 | }), 278 | (c.prototype.next = function () { 279 | return this.sliding ? void 0 : this.slide("next"); 280 | }), 281 | (c.prototype.prev = function () { 282 | return this.sliding ? void 0 : this.slide("prev"); 283 | }), 284 | (c.prototype.slide = function (b, d) { 285 | var e = this.$element.find(".item.active"), 286 | f = d || this.getItemForDirection(b, e), 287 | g = this.interval, 288 | h = "next" == b ? "left" : "right", 289 | i = "next" == b ? "first" : "last", 290 | j = this; 291 | if (!f.length) { 292 | if (!this.options.wrap) return; 293 | f = this.$element.find(".item")[i](); 294 | } 295 | if (f.hasClass("active")) return (this.sliding = !1); 296 | var k = f[0], 297 | l = a.Event("slide.bs.carousel", { relatedTarget: k, direction: h }); 298 | if ((this.$element.trigger(l), !l.isDefaultPrevented())) { 299 | if ( 300 | ((this.sliding = !0), g && this.pause(), this.$indicators.length) 301 | ) { 302 | this.$indicators.find(".active").removeClass("active"); 303 | var m = a(this.$indicators.children()[this.getItemIndex(f)]); 304 | m && m.addClass("active"); 305 | } 306 | var n = a.Event("slid.bs.carousel", { 307 | relatedTarget: k, 308 | direction: h, 309 | }); 310 | return ( 311 | a.support.transition && this.$element.hasClass("slide") 312 | ? (f.addClass(b), 313 | f[0].offsetWidth, 314 | e.addClass(h), 315 | f.addClass(h), 316 | e 317 | .one("bsTransitionEnd", function () { 318 | f.removeClass([b, h].join(" ")).addClass("active"), 319 | e.removeClass(["active", h].join(" ")), 320 | (j.sliding = !1), 321 | setTimeout(function () { 322 | j.$element.trigger(n); 323 | }, 0); 324 | }) 325 | .emulateTransitionEnd(c.TRANSITION_DURATION)) 326 | : (e.removeClass("active"), 327 | f.addClass("active"), 328 | (this.sliding = !1), 329 | this.$element.trigger(n)), 330 | g && this.cycle(), 331 | this 332 | ); 333 | } 334 | }); 335 | var d = a.fn.carousel; 336 | (a.fn.carousel = b), 337 | (a.fn.carousel.Constructor = c), 338 | (a.fn.carousel.noConflict = function () { 339 | return (a.fn.carousel = d), this; 340 | }); 341 | var e = function (c) { 342 | var d, 343 | e = a(this), 344 | f = a( 345 | e.attr("data-target") || 346 | ((d = e.attr("href")) && d.replace(/.*(?=#[^\s]+$)/, "")) 347 | ); 348 | if (f.hasClass("carousel")) { 349 | var g = a.extend({}, f.data(), e.data()), 350 | h = e.attr("data-slide-to"); 351 | h && (g.interval = !1), 352 | b.call(f, g), 353 | h && f.data("bs.carousel").to(h), 354 | c.preventDefault(); 355 | } 356 | }; 357 | a(document) 358 | .on("click.bs.carousel.data-api", "[data-slide]", e) 359 | .on("click.bs.carousel.data-api", "[data-slide-to]", e), 360 | a(window).on("load", function () { 361 | a('[data-ride="carousel"]').each(function () { 362 | var c = a(this); 363 | b.call(c, c.data()); 364 | }); 365 | }); 366 | })(jQuery), 367 | +(function (a) { 368 | "use strict"; 369 | function b(b) { 370 | var c, 371 | d = 372 | b.attr("data-target") || 373 | ((c = b.attr("href")) && c.replace(/.*(?=#[^\s]+$)/, "")); 374 | return a(d); 375 | } 376 | function c(b) { 377 | return this.each(function () { 378 | var c = a(this), 379 | e = c.data("bs.collapse"), 380 | f = a.extend({}, d.DEFAULTS, c.data(), "object" == typeof b && b); 381 | !e && f.toggle && "show" == b && (f.toggle = !1), 382 | e || c.data("bs.collapse", (e = new d(this, f))), 383 | "string" == typeof b && e[b](); 384 | }); 385 | } 386 | var d = function (b, c) { 387 | (this.$element = a(b)), 388 | (this.options = a.extend({}, d.DEFAULTS, c)), 389 | (this.$trigger = a(this.options.trigger).filter( 390 | '[href="#' + b.id + '"], [data-target="#' + b.id + '"]' 391 | )), 392 | (this.transitioning = null), 393 | this.options.parent 394 | ? (this.$parent = this.getParent()) 395 | : this.addAriaAndCollapsedClass(this.$element, this.$trigger), 396 | this.options.toggle && this.toggle(); 397 | }; 398 | (d.VERSION = "3.3.1"), 399 | (d.TRANSITION_DURATION = 350), 400 | (d.DEFAULTS = { toggle: !0, trigger: '[data-toggle="collapse"]' }), 401 | (d.prototype.dimension = function () { 402 | var a = this.$element.hasClass("width"); 403 | return a ? "width" : "height"; 404 | }), 405 | (d.prototype.show = function () { 406 | if (!this.transitioning && !this.$element.hasClass("in")) { 407 | var b, 408 | e = 409 | this.$parent && 410 | this.$parent.find("> .panel").children(".in, .collapsing"); 411 | if ( 412 | !( 413 | e && 414 | e.length && 415 | ((b = e.data("bs.collapse")), b && b.transitioning) 416 | ) 417 | ) { 418 | var f = a.Event("show.bs.collapse"); 419 | if ((this.$element.trigger(f), !f.isDefaultPrevented())) { 420 | e && 421 | e.length && 422 | (c.call(e, "hide"), b || e.data("bs.collapse", null)); 423 | var g = this.dimension(); 424 | this.$element 425 | .removeClass("collapse") 426 | .addClass("collapsing") 427 | [g](0) 428 | .attr("aria-expanded", !0), 429 | this.$trigger 430 | .removeClass("collapsed") 431 | .attr("aria-expanded", !0), 432 | (this.transitioning = 1); 433 | var h = function () { 434 | this.$element 435 | .removeClass("collapsing") 436 | .addClass("collapse in") 437 | [g](""), 438 | (this.transitioning = 0), 439 | this.$element.trigger("shown.bs.collapse"); 440 | }; 441 | if (!a.support.transition) return h.call(this); 442 | var i = a.camelCase(["scroll", g].join("-")); 443 | this.$element 444 | .one("bsTransitionEnd", a.proxy(h, this)) 445 | .emulateTransitionEnd(d.TRANSITION_DURATION) 446 | [g](this.$element[0][i]); 447 | } 448 | } 449 | } 450 | }), 451 | (d.prototype.hide = function () { 452 | if (!this.transitioning && this.$element.hasClass("in")) { 453 | var b = a.Event("hide.bs.collapse"); 454 | if ((this.$element.trigger(b), !b.isDefaultPrevented())) { 455 | var c = this.dimension(); 456 | this.$element[c](this.$element[c]())[0].offsetHeight, 457 | this.$element 458 | .addClass("collapsing") 459 | .removeClass("collapse in") 460 | .attr("aria-expanded", !1), 461 | this.$trigger.addClass("collapsed").attr("aria-expanded", !1), 462 | (this.transitioning = 1); 463 | var e = function () { 464 | (this.transitioning = 0), 465 | this.$element 466 | .removeClass("collapsing") 467 | .addClass("collapse") 468 | .trigger("hidden.bs.collapse"); 469 | }; 470 | return a.support.transition 471 | ? void this.$element[c](0) 472 | .one("bsTransitionEnd", a.proxy(e, this)) 473 | .emulateTransitionEnd(d.TRANSITION_DURATION) 474 | : e.call(this); 475 | } 476 | } 477 | }), 478 | (d.prototype.toggle = function () { 479 | this[this.$element.hasClass("in") ? "hide" : "show"](); 480 | }), 481 | (d.prototype.getParent = function () { 482 | return a(this.options.parent) 483 | .find( 484 | '[data-toggle="collapse"][data-parent="' + 485 | this.options.parent + 486 | '"]' 487 | ) 488 | .each( 489 | a.proxy(function (c, d) { 490 | var e = a(d); 491 | this.addAriaAndCollapsedClass(b(e), e); 492 | }, this) 493 | ) 494 | .end(); 495 | }), 496 | (d.prototype.addAriaAndCollapsedClass = function (a, b) { 497 | var c = a.hasClass("in"); 498 | a.attr("aria-expanded", c), 499 | b.toggleClass("collapsed", !c).attr("aria-expanded", c); 500 | }); 501 | var e = a.fn.collapse; 502 | (a.fn.collapse = c), 503 | (a.fn.collapse.Constructor = d), 504 | (a.fn.collapse.noConflict = function () { 505 | return (a.fn.collapse = e), this; 506 | }), 507 | a(document).on( 508 | "click.bs.collapse.data-api", 509 | '[data-toggle="collapse"]', 510 | function (d) { 511 | var e = a(this); 512 | e.attr("data-target") || d.preventDefault(); 513 | var f = b(e), 514 | g = f.data("bs.collapse"), 515 | h = g ? "toggle" : a.extend({}, e.data(), { trigger: this }); 516 | c.call(f, h); 517 | } 518 | ); 519 | })(jQuery), 520 | +(function (a) { 521 | "use strict"; 522 | function b(b) { 523 | (b && 3 === b.which) || 524 | (a(e).remove(), 525 | a(f).each(function () { 526 | var d = a(this), 527 | e = c(d), 528 | f = { relatedTarget: this }; 529 | e.hasClass("open") && 530 | (e.trigger((b = a.Event("hide.bs.dropdown", f))), 531 | b.isDefaultPrevented() || 532 | (d.attr("aria-expanded", "false"), 533 | e.removeClass("open").trigger("hidden.bs.dropdown", f))); 534 | })); 535 | } 536 | function c(b) { 537 | var c = b.attr("data-target"); 538 | c || 539 | ((c = b.attr("href")), 540 | (c = c && /#[A-Za-z]/.test(c) && c.replace(/.*(?=#[^\s]*$)/, ""))); 541 | var d = c && a(c); 542 | return d && d.length ? d : b.parent(); 543 | } 544 | function d(b) { 545 | return this.each(function () { 546 | var c = a(this), 547 | d = c.data("bs.dropdown"); 548 | d || c.data("bs.dropdown", (d = new g(this))), 549 | "string" == typeof b && d[b].call(c); 550 | }); 551 | } 552 | var e = ".dropdown-backdrop", 553 | f = '[data-toggle="dropdown"]', 554 | g = function (b) { 555 | a(b).on("click.bs.dropdown", this.toggle); 556 | }; 557 | (g.VERSION = "3.3.1"), 558 | (g.prototype.toggle = function (d) { 559 | var e = a(this); 560 | if (!e.is(".disabled, :disabled")) { 561 | var f = c(e), 562 | g = f.hasClass("open"); 563 | if ((b(), !g)) { 564 | "ontouchstart" in document.documentElement && 565 | !f.closest(".navbar-nav").length && 566 | a('