const express = require('express'); const router = express.Router(); const colors = require('colors'); const hash = require('object-hash'); const stripHtml = require('string-strip-html'); const moment = require('moment'); const _ = require('lodash'); const { getId, hooker, clearSessionValue, sortMenu, getMenu, getPaymentConfig, getImages, updateTotalCart, emptyCart, updateSubscriptionCheck, paginateData, addSitemapProducts, getCountryList } = require('../lib/common'); const countryList = getCountryList(); // These is the customer facing routes router.get('/payment/:orderId', async (req, res, next) => { const db = req.app.db; const config = req.app.config; // Get the order const order = await db.orders.findOne({ _id: getId(req.params.orderId) }); if(!order){ res.render('error', { title: 'Not found', message: 'Order not found', helpers: req.handlebars.helpers, config }); return; } // If stock management is turned on payment approved update stock level if(config.trackStock && req.session.paymentApproved){ // Check to see if already updated to avoid duplicate updating of stock if(order.productStockUpdated !== true){ Object.keys(order.orderProducts).forEach(async (productKey) => { const product = order.orderProducts[productKey]; const dbProduct = await db.products.findOne({ _id: getId(product.productId) }); let newStockLevel = dbProduct.productStock - product.quantity; if(newStockLevel < 1){ newStockLevel = 0; } // Update product stock await db.products.updateOne({ _id: getId(product.productId) }, { $set: { productStock: newStockLevel } }, { multi: false }); // Add stock updated flag to order await db.orders.updateOne({ _id: getId(order._id) }, { $set: { productStockUpdated: true } }, { multi: false }); }); console.info('Updated stock levels'); } } // If hooks are configured, send hook if(config.orderHook){ await hooker(order); }; let paymentView = `${config.themeViews}payment-complete`; if(order.orderPaymentGateway === 'Blockonomics') paymentView = `${config.themeViews}payment-complete-blockonomics`; res.render(paymentView, { title: 'Payment complete', config: req.app.config, session: req.session, result: order, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(await getMenu(db)) }); }); router.get('/emptycart', async (req, res, next) => { emptyCart(req, res, ''); }); router.get('/checkout/information', async (req, res, next) => { const config = req.app.config; // if there is no items in the cart then render a failure if(!req.session.cart){ req.session.message = 'The are no items in your cart. Please add some items before checking out'; req.session.messageType = 'danger'; res.redirect('/'); return; } let paymentType = ''; if(req.session.cartSubscription){ paymentType = '_subscription'; } // render the payment page res.render(`${config.themeViews}checkout-information`, { title: 'Checkout - Information', config: req.app.config, session: req.session, paymentType, cartClose: false, page: 'checkout-information', countryList, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }); router.get('/checkout/shipping', async (req, res, next) => { const config = req.app.config; // if there is no items in the cart then render a failure if(!req.session.cart){ req.session.message = 'The are no items in your cart. Please add some items before checking out'; req.session.messageType = 'danger'; res.redirect('/'); return; } if(!req.session.customerEmail){ req.session.message = 'Cannot proceed to shipping without customer information'; req.session.messageType = 'danger'; res.redirect('/checkout/information'); return; } // Net cart amount const netCartAmount = req.session.totalCartAmount - req.session.totalCartShipping || 0; // Recalculate shipping config.modules.loaded.shipping.calculateShipping( netCartAmount, config, req ); // render the payment page res.render(`${config.themeViews}checkout-shipping`, { title: 'Checkout - Shipping', config: req.app.config, session: req.session, cartClose: false, cartReadOnly: true, page: 'checkout-shipping', countryList, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }); router.get('/checkout/cart', (req, res) => { const config = req.app.config; res.render(`${config.themeViews}checkout-cart`, { title: 'Checkout - Cart', page: req.query.path, config, session: req.session, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }); router.get('/checkout/cartdata', (req, res) => { const config = req.app.config; res.status(200).json({ cart: req.session.cart, session: req.session, currencySymbol: config.currencySymbol || '$' }); }); router.get('/checkout/payment', async (req, res) => { const config = req.app.config; // if there is no items in the cart then render a failure if(!req.session.cart){ req.session.message = 'The are no items in your cart. Please add some items before checking out'; req.session.messageType = 'danger'; res.redirect('/'); return; } let paymentType = ''; if(req.session.cartSubscription){ paymentType = '_subscription'; } // update total cart amount one last time before payment await updateTotalCart(req, res); res.render(`${config.themeViews}checkout-payment`, { title: 'Checkout - Payment', config: req.app.config, paymentConfig: getPaymentConfig(), session: req.session, paymentPage: true, paymentType, cartClose: true, cartReadOnly: true, page: 'checkout-information', countryList, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }); router.get('/blockonomics_payment', (req, res, next) => { const config = req.app.config; let paymentType = ''; if(req.session.cartSubscription){ paymentType = '_subscription'; } // show bitcoin address and wait for payment, subscribing to wss res.render(`${config.themeViews}checkout-blockonomics`, { title: 'Checkout - Payment', config: req.app.config, paymentConfig: getPaymentConfig(), session: req.session, paymentPage: true, paymentType, cartClose: true, cartReadOnly: true, page: 'checkout-information', countryList, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }); router.post('/checkout/adddiscountcode', async (req, res) => { const config = req.app.config; const db = req.app.db; // if there is no items in the cart return a failure if(!req.session.cart){ res.status(400).json({ message: 'The are no items in your cart.' }); return; } // Check if the discount module is loaded if(!config.modules.loaded.discount){ res.status(400).json({ message: 'Access denied.' }); return; } // Check defined or null if(!req.body.discountCode || req.body.discountCode === ''){ res.status(400).json({ message: 'Discount code is invalid or expired' }); return; } // Validate discount code const discount = await db.discounts.findOne({ code: req.body.discountCode }); if(!discount){ res.status(400).json({ message: 'Discount code is invalid or expired' }); return; } // Validate date validity if(!moment().isBetween(moment(discount.start), moment(discount.end))){ res.status(400).json({ message: 'Discount is expired' }); return; } // Set the discount code req.session.discountCode = discount.code; // Update the cart amount await updateTotalCart(req, res); // Return the message res.status(200).json({ message: 'Discount code applied' }); }); router.post('/checkout/removediscountcode', async (req, res) => { // if there is no items in the cart return a failure if(!req.session.cart){ res.status(400).json({ message: 'The are no items in your cart.' }); return; } // Delete the discount code delete req.session.discountCode; // update total cart amount await updateTotalCart(req, res); // Return the message res.status(200).json({ message: 'Discount code removed' }); }); // show an individual product router.get('/product/:id', async (req, res) => { const db = req.app.db; const config = req.app.config; const productsIndex = req.app.productsIndex; const product = await db.products.findOne({ $or: [{ _id: getId(req.params.id) }, { productPermalink: req.params.id }] }); if(!product){ res.render('error', { title: 'Not found', message: 'Order not found', helpers: req.handlebars.helpers, config }); return; } if(product.productPublished === false){ res.render('error', { title: 'Not found', message: 'Product not found', helpers: req.handlebars.helpers, config }); return; } const productOptions = product.productOptions; // If JSON query param return json instead if(req.query.json === 'true'){ res.status(200).json(product); return; } // show the view const images = await getImages(product._id, req, res); // Related products let relatedProducts = {}; if(config.showRelatedProducts){ const lunrIdArray = []; const productTags = product.productTags.split(','); const productTitleWords = product.productTitle.split(' '); const searchWords = productTags.concat(productTitleWords); searchWords.forEach((word) => { productsIndex.search(word).forEach((id) => { lunrIdArray.push(getId(id.ref)); }); }); relatedProducts = await db.products.find({ _id: { $in: lunrIdArray, $ne: product._id } }).limit(4).toArray(); } res.render(`${config.themeViews}product`, { title: product.productTitle, result: product, productOptions: productOptions, images: images, relatedProducts, productDescription: stripHtml(product.productDescription), metaDescription: config.cartTitle + ' - ' + product.productTitle, config: config, session: req.session, pageUrl: config.baseUrl + req.originalUrl, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(await getMenu(db)) }); }); // Gets the current cart router.get('/cart/retrieve', async (req, res, next) => { const db = req.app.db; // Get the cart from the DB using the session id const cart = await db.cart.findOne({ sessionId: getId(req.session.id) }); res.status(200).json({ cart: cart.cart }); }); // Updates a single product quantity router.post('/product/updatecart', async (req, res, next) => { const db = req.app.db; const config = req.app.config; const cartItem = req.body; // Check cart exists if(!req.session.cart){ emptyCart(req, res, 'json', 'There are no items if your cart or your cart is expired'); return; } const product = await db.products.findOne({ _id: getId(cartItem.productId) }); if(!product){ res.status(400).json({ message: 'There was an error updating the cart', totalCartItems: Object.keys(req.session.cart).length }); return; } // Calculate the quantity to update let productQuantity = cartItem.quantity ? cartItem.quantity : 1; if(typeof productQuantity === 'string'){ productQuantity = parseInt(productQuantity); } if(productQuantity === 0){ // quantity equals zero so we remove the item delete req.session.cart[cartItem.cartId]; res.status(400).json({ message: 'There was an error updating the cart', totalCartItems: Object.keys(req.session.cart).length }); return; } // If stock management on check there is sufficient stock for this product if(config.trackStock && product.productStock){ if(productQuantity > product.productStock){ res.status(400).json({ message: 'There is insufficient stock of this product.', totalCartItems: Object.keys(req.session.cart).length }); return; } } const productPrice = parseFloat(product.productPrice).toFixed(2); if(!req.session.cart[cartItem.cartId]){ res.status(400).json({ message: 'There was an error updating the cart', totalCartItems: Object.keys(req.session.cart).length }); return; } // Update the cart req.session.cart[cartItem.cartId].quantity = productQuantity; req.session.cart[cartItem.cartId].totalItemPrice = productPrice * productQuantity; // update total cart amount await updateTotalCart(req, res); // Update checking cart for subscription updateSubscriptionCheck(req, res); // Update cart to the DB await db.cart.updateOne({ sessionId: req.session.id }, { $set: { cart: req.session.cart } }); res.status(200).json({ message: 'Cart successfully updated', totalCartItems: Object.keys(req.session.cart).length }); }); // Remove single product from cart router.post('/product/removefromcart', async (req, res, next) => { const db = req.app.db; // Check for item in cart if(!req.session.cart[req.body.cartId]){ return res.status(400).json({ message: 'Product not found in cart' }); } // remove item from cart delete req.session.cart[req.body.cartId]; // If not items in cart, empty it if(Object.keys(req.session.cart).length === 0){ return emptyCart(req, res, 'json'); } // Update cart in DB await db.cart.updateOne({ sessionId: req.session.id }, { $set: { cart: req.session.cart } }); // update total cart await updateTotalCart(req, res); // Update checking cart for subscription updateSubscriptionCheck(req, res); return res.status(200).json({ message: 'Product successfully removed', totalCartItems: Object.keys(req.session.cart).length }); }); // Totally empty the cart router.post('/product/emptycart', async (req, res, next) => { emptyCart(req, res, 'json'); }); // Add item to cart router.post('/product/addtocart', async (req, res, next) => { const db = req.app.db; const config = req.app.config; let productQuantity = req.body.productQuantity ? parseInt(req.body.productQuantity) : 1; const productComment = req.body.productComment ? req.body.productComment : null; // If maxQuantity set, ensure the quantity doesn't exceed that value if(config.maxQuantity && productQuantity > config.maxQuantity){ return res.status(400).json({ message: 'The quantity exceeds the max amount. Please contact us for larger orders.' }); } // Don't allow negative quantity if(productQuantity < 1){ productQuantity = 1; } // setup cart object if it doesn't exist if(!req.session.cart){ req.session.cart = {}; } // Get the product from the DB const product = await db.products.findOne({ _id: getId(req.body.productId) }); // No product found if(!product){ return res.status(400).json({ message: 'Error updating cart. Please try again.' }); } // If cart already has a subscription you cannot add anything else if(req.session.cartSubscription){ return res.status(400).json({ message: 'Subscription already existing in cart. You cannot add more.' }); } // If existing cart isn't empty check if product is a subscription if(Object.keys(req.session.cart).length !== 0){ if(product.productSubscription){ return res.status(400).json({ message: 'You cannot combine subscription products with existing in your cart. Empty your cart and try again.' }); } } // If stock management on check there is sufficient stock for this product if(config.trackStock){ // Only if not disabled if(product.productStockDisable !== true){ // If there is more stock than total (ignoring held) if(productQuantity > product.productStock){ return res.status(400).json({ message: 'There is insufficient stock of this product.' }); } const stockHeld = await db.cart.aggregate( { $match: { cart: { $elemMatch: { productId: product._id.toString() } } } }, { $unwind: '$cart' }, { $group: { _id: '$cart.productId', sumHeld: { $sum: '$cart.quantity' } } }, { $project: { sumHeld: 1 } } ).toArray(); // If there is stock if(stockHeld.length > 0){ const totalHeld = _.find(stockHeld, { _id: product._id.toString() }).sumHeld; const netStock = product.productStock - totalHeld; // Check there is sufficient stock if(productQuantity > netStock){ return res.status(400).json({ message: 'There is insufficient stock of this product.' }); } } } } const productPrice = parseFloat(product.productPrice).toFixed(2); let options = {}; if(req.body.productOptions){ try{ if(typeof req.body.productOptions === 'object'){ options = req.body.productOptions; }else{ options = JSON.parse(req.body.productOptions); } }catch(ex){} } // Product with options hash const productHash = hash({ productId: product._id.toString(), options }); // if exists we add to the existing value let cartQuantity = 0; if(req.session.cart[productHash]){ cartQuantity = parseInt(req.session.cart[productHash].quantity) + productQuantity; req.session.cart[productHash].quantity = cartQuantity; req.session.cart[productHash].totalItemPrice = productPrice * parseInt(req.session.cart[productHash].quantity); }else{ // Set the card quantity cartQuantity = productQuantity; // new product deets const productObj = {}; productObj.productId = product._id; productObj.title = product.productTitle; productObj.quantity = productQuantity; productObj.totalItemPrice = productPrice * productQuantity; productObj.options = options; productObj.productImage = product.productImage; productObj.productComment = productComment; productObj.productSubscription = product.productSubscription; if(product.productPermalink){ productObj.link = product.productPermalink; }else{ productObj.link = product._id; } // merge into the current cart req.session.cart[productHash] = productObj; } // Update cart to the DB await db.cart.updateOne({ sessionId: req.session.id }, { $set: { cart: req.session.cart } }, { upsert: true }); // update total cart amount await updateTotalCart(req, res); // Update checking cart for subscription updateSubscriptionCheck(req, res); if(product.productSubscription){ req.session.cartSubscription = product.productSubscription; } return res.status(200).json({ message: 'Cart successfully updated', cartId: productHash, totalCartItems: req.session.totalCartItems }); }); // search products router.get('/search/:searchTerm/:pageNum?', (req, res) => { const db = req.app.db; const searchTerm = req.params.searchTerm; const productsIndex = req.app.productsIndex; const config = req.app.config; const numberProducts = config.productsPerPage ? config.productsPerPage : 6; const lunrIdArray = []; productsIndex.search(searchTerm).forEach((id) => { lunrIdArray.push(getId(id.ref)); }); let pageNum = 1; if(req.params.pageNum){ pageNum = req.params.pageNum; } Promise.all([ paginateData(true, req, pageNum, 'products', { _id: { $in: lunrIdArray } }), getMenu(db) ]) .then(([results, menu]) => { // If JSON query param return json instead if(req.query.json === 'true'){ res.status(200).json(results.data); return; } res.render(`${config.themeViews}index`, { title: 'Results', results: results.data, filtered: true, session: req.session, metaDescription: req.app.config.cartTitle + ' - Search term: ' + searchTerm, searchTerm: searchTerm, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), productsPerPage: numberProducts, totalProductCount: results.totalItems, pageNum: pageNum, paginateUrl: 'search', config: config, menu: sortMenu(menu), helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }) .catch((err) => { console.error(colors.red('Error searching for products', err)); }); }); // search products router.get('/category/:cat/:pageNum?', (req, res) => { const db = req.app.db; const searchTerm = req.params.cat; const productsIndex = req.app.productsIndex; const config = req.app.config; const numberProducts = config.productsPerPage ? config.productsPerPage : 6; const lunrIdArray = []; productsIndex.search(searchTerm).forEach((id) => { lunrIdArray.push(getId(id.ref)); }); let pageNum = 1; if(req.params.pageNum){ pageNum = req.params.pageNum; } Promise.all([ paginateData(true, req, pageNum, 'products', { _id: { $in: lunrIdArray } }), getMenu(db) ]) .then(([results, menu]) => { const sortedMenu = sortMenu(menu); // If JSON query param return json instead if(req.query.json === 'true'){ res.status(200).json(results.data); return; } res.render(`${config.themeViews}index`, { title: `Category: ${searchTerm}`, results: results.data, filtered: true, session: req.session, searchTerm: searchTerm, metaDescription: `${req.app.config.cartTitle} - Category: ${searchTerm}`, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), productsPerPage: numberProducts, totalProductCount: results.totalItems, pageNum: pageNum, menuLink: _.find(sortedMenu.items, (obj) => { return obj.link === searchTerm; }), paginateUrl: 'category', config: config, menu: sortedMenu, helpers: req.handlebars.helpers, showFooter: 'showFooter' }); }) .catch((err) => { console.error(colors.red('Error getting products for category', err)); }); }); // Language setup in cookie router.get('/lang/:locale', (req, res) => { res.cookie('locale', req.params.locale, { maxAge: 900000, httpOnly: true }); res.redirect('back'); }); // return sitemap router.get('/sitemap.xml', (req, res, next) => { const sm = require('sitemap'); const config = req.app.config; addSitemapProducts(req, res, (err, products) => { if(err){ console.error(colors.red('Error generating sitemap.xml', err)); } const sitemap = sm.createSitemap( { hostname: config.baseUrl, cacheTime: 600000, urls: [ { url: '/', changefreq: 'weekly', priority: 1.0 } ] }); const currentUrls = sitemap.urls; const mergedUrls = currentUrls.concat(products); sitemap.urls = mergedUrls; // render the sitemap sitemap.toXML((err, xml) => { if(err){ return res.status(500).end(); } res.header('Content-Type', 'application/xml'); res.send(xml); return true; }); }); }); router.get('/page/:pageNum', (req, res, next) => { const db = req.app.db; const config = req.app.config; const numberProducts = config.productsPerPage ? config.productsPerPage : 6; Promise.all([ paginateData(true, req, req.params.pageNum, 'products'), getMenu(db) ]) .then(([results, menu]) => { // If JSON query param return json instead if(req.query.json === 'true'){ res.status(200).json(results.data); return; } res.render(`${config.themeViews}index`, { title: 'Shop', results: results.data, session: req.session, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), metaDescription: req.app.config.cartTitle + ' - Products page: ' + req.params.pageNum, config: req.app.config, productsPerPage: numberProducts, totalProductCount: results.totalItems, pageNum: req.params.pageNum, paginateUrl: 'page', helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(menu) }); }) .catch((err) => { console.error(colors.red('Error getting products for page', err)); }); }); // The main entry point of the shop router.get('/:page?', async (req, res, next) => { const db = req.app.db; const config = req.app.config; const numberProducts = config.productsPerPage ? config.productsPerPage : 6; // if no page is specified, just render page 1 of the cart if(!req.params.page){ Promise.all([ paginateData(true, req, 1, 'products', {}), getMenu(db) ]) .then(([results, menu]) => { // If JSON query param return json instead if(req.query.json === 'true'){ res.status(200).json(results.data); return; } res.render(`${config.themeViews}index`, { title: `${config.cartTitle} - Shop`, theme: config.theme, results: results.data, session: req.session, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), config, productsPerPage: numberProducts, totalProductCount: results.totalItems, pageNum: 1, paginateUrl: 'page', helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(menu) }); }) .catch((err) => { console.error(colors.red('Error getting products for page', err)); }); }else{ if(req.params.page === 'admin'){ next(); return; } // lets look for a page const page = await db.pages.findOne({ pageSlug: req.params.page, pageEnabled: 'true' }); // if we have a page lets render it, else throw 404 if(page){ res.render(`${config.themeViews}page`, { title: page.pageName, page: page, searchTerm: req.params.page, session: req.session, message: clearSessionValue(req.session, 'message'), messageType: clearSessionValue(req.session, 'messageType'), config: req.app.config, metaDescription: req.app.config.cartTitle + ' - ' + page, helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(await getMenu(db)) }); }else{ res.status(404).render('error', { title: '404 Error - Page not found', config: req.app.config, message: '404 Error - Page not found', helpers: req.handlebars.helpers, showFooter: 'showFooter', menu: sortMenu(await getMenu(db)) }); } } }); module.exports = router;