diff --git a/.gitignore b/.gitignore index 9057303e..744629f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ data/ -public/uploads /config/*-local.json .vscode **.DS_Store diff --git a/app.js b/app.js index ffb61e25..92ad93e6 100644 --- a/app.js +++ b/app.js @@ -323,6 +323,12 @@ handlebars = handlebars.create({ > `; + }, + eqHidden: (lvalue, rvalue) => { + return lvalue === rvalue ? 'd-none' : 'd-flex'; + }, + neqHidden: (lvalue, rvalue) => { + return lvalue !== rvalue ? 'd-none' : 'd-flex'; } } }); @@ -348,6 +354,7 @@ if(!config.secretSession || config.secretSession === ''){ app.enable('trust proxy'); app.use(helmet()); app.set('port', process.env.PORT || 1111); +app.use('/imgs', express.static(path.join(__dirname, 'uploads'))); app.use(logger('dev')); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser(config.secretCookie)); diff --git a/config/settings.json b/config/settings.json index ba5db3e4..96f0fb19 100644 --- a/config/settings.json +++ b/config/settings.json @@ -53,6 +53,8 @@ "peeridClientSecret": "Qv16Go2MPjBhn8P1Sdc7LgzPnr18F3JLGjC7F71nXw", "peeridRedirectUri": "https://nftstore.peerplays.download/peerid_auth/redirect", "peerplaysAssetID": "1.3.0", + "peerplaysAssetSymbol": "TEST", + "peerplaysAssetPrecision": 5, "peerplaysAccountID": "1.2.73", "commission": 10, "categories": ["Digital Art", "Photographs", "Oil Paintings"] diff --git a/config/settingsSchema.json b/config/settingsSchema.json index 09347bae..fee9e2cd 100644 --- a/config/settingsSchema.json +++ b/config/settingsSchema.json @@ -191,31 +191,39 @@ }, "peeridUrl": { "type": "string", - "default": false + "default": "" }, "peeridClientID": { "type": "string", - "default": false + "default": "" }, "peeridClientSecret": { "type": "string", - "default": false + "default": "" }, "peeridRedirectUri": { "type": "string", - "default": false + "default": "" }, "peerplaysAssetID": { "type": "string", - "default": false + "default": "" + }, + "peerplaysAssetSymbol": { + "type": "string", + "default": "" + }, + "peerplaysAssetPrecision": { + "type": "number", + "default": 5 }, "peerplaysAccountID": { "type": "string", - "default": false + "default": "" }, "commission": { "type": "number", - "default": false + "default": 0 }, "categories": { "type": "array" diff --git a/lib/paginate.js b/lib/paginate.js index 216624ef..83289f75 100644 --- a/lib/paginate.js +++ b/lib/paginate.js @@ -1,3 +1,4 @@ +const PeerplaysService = require('../services/PeerplaysService'); const { getConfig } = require('./config'); diff --git a/lib/schemas/editProduct.json b/lib/schemas/editProduct.json index 1bec4f1b..7ea313fd 100644 --- a/lib/schemas/editProduct.json +++ b/lib/schemas/editProduct.json @@ -10,6 +10,11 @@ "isNotEmpty": true, "minLength": 6 }, + "productTitle": { + "type": "string", + "isNotEmpty": true, + "minLength": 2 + }, "productDescription": { "type": "string", "isNotEmpty": true, @@ -25,6 +30,10 @@ "productPermalink": { "type": "string", "isNotEmpty": true + }, + "owner": { + "type": "string", + "isNotEmpty": true } }, "errorMessage": { @@ -36,6 +45,10 @@ } }, "required": [ - "productId" + "productId", + "nftMetadataID", + "productTitle", + "productDescription", + "owner" ] } \ No newline at end of file diff --git a/lib/schemas/newProduct.json b/lib/schemas/newProduct.json index b84c75b3..c52fac24 100644 --- a/lib/schemas/newProduct.json +++ b/lib/schemas/newProduct.json @@ -7,6 +7,11 @@ "isNotEmpty": true, "minLength": 6 }, + "productTitle": { + "type": "string", + "isNotEmpty": true, + "minLength": 2 + }, "productDescription": { "type": "string", "isNotEmpty": true, @@ -22,6 +27,10 @@ "productPermalink": { "type": "string", "isNotEmpty": true + }, + "owner": { + "type": "string", + "isNotEmpty": true } }, "errorMessage": { @@ -33,6 +42,8 @@ }, "required": [ "nftMetadataID", - "productDescription" + "productTitle", + "productDescription", + "owner" ] } \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index e04b22a7..e8eb56ee 100644 --- a/locales/en.json +++ b/locales/en.json @@ -52,8 +52,13 @@ "Save product": "Save NFT", "Edit product": "Edit NFT", "Product title": "NFT Title", + "Product quantity": "Quantity", "Product image": "NFT Image", "Product price": "NFT price", + "Product min price": "Min. price", + "Product max price": "Max. price", + "Product allow bid": "Allow bidding on the NFT", + "Sale end": "Sale end date", "Published": "Published", "Draft": "Draft", "Stock level": "Stock level", diff --git a/locales/it.json b/locales/it.json index b0c7b8d2..e32f896d 100644 --- a/locales/it.json +++ b/locales/it.json @@ -53,8 +53,13 @@ "Save product": "Salva NFT", "Edit product": "Modifica NFT", "Product title": "Titolo NFT", + "Product quantity": "Quantità", "Product image": "Immagine NFT", "Product price": "Prezzo NFT", + "Product min price": "Prezzo minimo", + "Product max price": "Prezzo massimo", + "Product allow bid": "Consenti offerte sulla NFT", + "Sale end": "Data di fine della vendita", "Published": "Pubblicato", "Draft": "Bozza", "Stock level": "Livello stock", diff --git a/public/javascripts/admin.js b/public/javascripts/admin.js index a13f6d82..c83e29fc 100644 --- a/public/javascripts/admin.js +++ b/public/javascripts/admin.js @@ -327,7 +327,7 @@ $(document).ready(function (){ // applies an product filter $(document).on('click', '#btn_product_filter', function(e){ if($('#product_filter').val() !== ''){ - window.location.href = '/products/filter/' + $('#product_filter').val(); + window.location.href = '/customer/products/filter/' + $('#product_filter').val(); }else{ showNotification('Please enter a keyword to filter', 'danger'); } diff --git a/public/javascripts/expressCart.js b/public/javascripts/expressCart.js index 3009845c..ae7e5962 100644 --- a/public/javascripts/expressCart.js +++ b/public/javascripts/expressCart.js @@ -91,10 +91,10 @@ $(document).ready(function (){ processData: false }) .done(function(msg){ - showNotification(msg.message, 'success', false, '/customer/product/edit/' + msg.productId); + showNotification(msg.message, 'success', false, '/customer/products/1'); }) .fail(function(msg){ - if(msg.responseJSON.length > 0){ + if(msg.responseJSON && msg.responseJSON.length > 0){ var errorMessages = validationErrors(msg.responseJSON); $('#validationModalBody').html(errorMessages); $('#validationModal').modal('show'); @@ -326,6 +326,126 @@ $(document).ready(function (){ window.location.replace('/customer/setup'); }); + $('#productButtons div button').on('click', function(e){ + if($(this).text() === 'Mint') { + var productId = $(this).attr('data-id'); + $('.modal-body #productId').val(productId); + $('#nftMintModal').modal('show'); + } else if($(this).text() === 'Sell') { + var productId = $(this).attr('data-id'); + $('.modal-body #sellProductId').val(productId); + $('#sellNFTModal').modal('show'); + $('#saleEnd').datetimepicker({ + uiLibrary: 'bootstrap4', + footer: true, + modal: true, + showOtherMonths: true + }); + } + }); + + // Mint NFT + $(document).on('click', '#buttonMint', function(e){ + $.ajax({ + method: 'POST', + url: '/customer/product/mint', + data: { + productId: $('#productId').val(), + quantity: $('#productQuantity').val() + } + }) + .done(function(msg){ + showNotification(msg.message, 'success', true); + }) + .fail(function(msg){ + if(msg.responseJSON.message === 'You need to be logged in to Mint NFT'){ + showNotification(msg.responseJSON.message, 'danger', false, '/customer/products'); + } + + if(msg.responseJSON.message === 'Product not found'){ + showNotification(msg.responseJSON.message, 'danger', false, '/customer/products'); + } + + showNotification(msg.responseJSON.message, 'danger'); + }); + }); + + $(document).on('click','#productSellTypeCheckbox', function(e) { + var assetSymbol = $('#assetSymbol').val(); + if($('#productSellTypeCheckbox').prop('checked')){ + const bidHtml = `
+ + +
+
+ + +
+
+ + +
`; + $('#sellNFTFormWrapper').html(bidHtml); + $('#saleEnd').datetimepicker({ + uiLibrary: 'bootstrap4', + footer: true, + modal: true, + showOtherMonths: true + }); + } else { + const fixedPriceHtml = `
+ + +
+
+ + +
+
+ + +
`; + $('#sellNFTFormWrapper').html(fixedPriceHtml); + $('#saleEnd').datetimepicker({ + uiLibrary: 'bootstrap4', + footer: true, + modal: true, + showOtherMonths: true + }); + } + }); + + // Sell NFT + $(document).on('click', '#buttonSell', function(e){ + const isBidding = $('#productSellTypeCheckbox').prop('checked'); + + $.ajax({ + method: 'POST', + url: '/customer/product/sell', + data: { + productId: $('#sellProductId').val(), + quantity: isBidding ? 1 : $('#productSellQuantity').val(), + minPrice: isBidding ? $('#productMinPrice').val() : $('#productPrice').val(), + maxPrice: isBidding ? $('#productMaxPrice').val() : $('#productPrice').val(), + expirationDate: $('#saleEnd').val() + } + }) + .done(function(msg){ + showNotification(msg.message, 'success', true); + }) + .fail(function(msg){ + if(msg.responseJSON.message === 'You need to be logged in to Mint NFT'){ + showNotification(msg.responseJSON.message, 'danger', false, '/customer/products'); + } + + if(msg.responseJSON.message === 'Product not found'){ + showNotification(msg.responseJSON.message, 'danger', false, '/customer/products'); + } + + showNotification(msg.responseJSON.message, 'danger'); + }); + }); + // call update settings API $('#customerLogin').on('click', function(e){ if(!e.isDefaultPrevented()){ @@ -872,4 +992,11 @@ function validationErrors(errors){ errorMessage += `

${value.dataPath.replace('/', '')} - ${value.message}

`; }); return errorMessage; +} + +function isNumberKey(evt){ + var charCode = (evt.which) ? evt.which : evt.keyCode + if (charCode > 31 && (charCode < 48 || charCode > 57)) + return false; + return true; } \ No newline at end of file diff --git a/public/uploads/expressCart.svg b/public/uploads/expressCart.svg new file mode 100644 index 00000000..730c4f02 --- /dev/null +++ b/public/uploads/expressCart.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + +express + + + + + + + diff --git a/public/uploads/logo-admin.png b/public/uploads/logo-admin.png new file mode 100644 index 00000000..8be226c1 Binary files /dev/null and b/public/uploads/logo-admin.png differ diff --git a/public/uploads/logo.png b/public/uploads/logo.png new file mode 100644 index 00000000..3c1d3b42 Binary files /dev/null and b/public/uploads/logo.png differ diff --git a/public/uploads/placeholder.png b/public/uploads/placeholder.png new file mode 100644 index 00000000..f5c52b55 Binary files /dev/null and b/public/uploads/placeholder.png differ diff --git a/routes/customer.js b/routes/customer.js index c8adc191..267d9ba8 100644 --- a/routes/customer.js +++ b/routes/customer.js @@ -556,7 +556,10 @@ router.post('/customer/login_action', async (req, res) => { indexCustomers(req.app) .then(() => { const returnCustomer = updatedCustomer.value; - delete returnCustomer.password; + + if(returnCustomer) { + delete returnCustomer.password; + } }); // Customer login successful req.session.customerPresent = true; diff --git a/routes/product.js b/routes/product.js index d128b837..a11044f9 100644 --- a/routes/product.js +++ b/routes/product.js @@ -21,6 +21,8 @@ const router = express.Router(); const config = require('../config/settings'); const multer = require('multer'); +const peerplaysService = new PeerplaysService(); + const randomizeLottoName = () => { let text = ''; const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -58,19 +60,106 @@ const upload = multer({ } }); +const getSellOffers = async (start = 0, k = 0) => { + let sellOffers = []; + const {result} = await peerplaysService.getBlockchainData({ + api: "database", + method: "list_sell_offers", + params: [`1.29.${start}`, 100] + }); + + let params = {}; + + for(let i = 0; i < result.length; i++) { + for(let j = 0; j < result[i].item_ids.length; j++, k++) { + params[`params[0][${k}]`] = result[i].item_ids[j]; + } + } + + const nfts = await peerplaysService.getBlockchainData({ + api: "database", + method: "get_objects", + ...params + }); + + if(nfts) { + for(let i = 0; i < result.length; i++) { + result[i].nft_metadata_ids = nfts.result.map((nft) => { + if(result[i].item_ids.includes(nft.id)) return nft.nft_metadata_id; + }); + + result[i].minimum_price.amount = result[i].minimum_price.amount / Math.pow(10, config.peerplaysAssetPrecision); + result[i].maximum_price.amount = result[i].maximum_price.amount / Math.pow(10, config.peerplaysAssetPrecision); + } + } + + sellOffers.push(...result); + + if(result.length < 100) { + return sellOffers; + } else { + sellOffers.push(await getSellOffers(start+100, k)); + return sellOffers; + } +} + router.get('/customer/products/:page?', async (req, res, next) => { + if(!req.session.peerplaysAccountId) { + res.redirect('/customer/login'); + return; + } + let pageNum = 1; if(req.params.page){ pageNum = req.params.page; } // Get our paginated data - const products = await paginateData(false, req, pageNum, 'products', {}, { productAddedDate: -1 }); + const products = await paginateData(false, req, pageNum, 'products', { owner: req.session.peerplaysAccountId }, { orderDate: -1 }); + + const allSellOffers = await getSellOffers(); + + if(products && products.data) { + await Promise.all(products.data.map(async (nft) => { + let metadata, minted, sellOffers; + try { + metadata = await peerplaysService.getBlockchainData({ + api: "database", + method: "get_objects", + "params[0][]": nft.nftMetadataID + }); + + minted = await peerplaysService.getBlockchainData({ + api: "database", + method: "nft_get_all_tokens", + "params[0]": nft.owner + }); + + sellOffers = allSellOffers ? allSellOffers.filter((s) => s.nft_metadata_ids.includes(nft.nftMetadataID)) : []; + sellOffersCount = sellOffers.reduce((sum, s) => sum + s.item_ids.length, 0); + + minted = minted ? minted.result.filter((m) => m.nft_metadata_id === nft.nftMetadataID) : []; + nft.minted = minted; + nft.mintedCount = minted.length; + nft.sellOffers = sellOffers; + nft.sellOffersCount = sellOffersCount; + } catch(ex) { + console.error(ex); + } + + if(metadata && metadata.result[0] && metadata.result[0].base_uri.includes('/uploads/')) { + nft.base_uri = req.protocol + '://' + req.get('host') + '/imgs' + metadata.result[0].base_uri.split('/uploads')[1]; + } else { + nft.base_uri = metadata.result[0].base_uri; + } + })); + } res.render('products', { - title: 'Cart - Products', + title: 'My NFTs', results: products.data, totalItemCount: products.totalItems, + allSellOffers, pageNum, paginateUrl: 'customer/products', resultType: 'top', @@ -86,15 +175,9 @@ router.get('/customer/products/:page?', async (req, res, next) => { router.get('/customer/products/filter/:search', async (req, res, next) => { const db = req.app.db; const searchTerm = req.params.search; - const productsIndex = req.app.productsIndex; - - const lunrIdArray = []; - productsIndex.search(searchTerm).forEach((id) => { - lunrIdArray.push(getId(id.ref)); - }); // we search on the lunr indexes - const results = await db.products.find({ _id: { $in: lunrIdArray } }).toArray(); + const results = await db.products.find({$or: [{ "productTitle": {$regex: searchTerm, $options: 'i'}}, {"productDescription": {$regex: searchTerm, $options: 'i'}}]}).toArray(); if(req.apiAuthenticated){ res.status(200).json(results); @@ -143,10 +226,12 @@ router.post('/customer/product/insert', upload.single("productImage"), async (re const doc = { nftMetadataID: "1.26.0", + productTitle: req.body.title, productDescription: req.body.productDescription, productCategory: req.body.productCategory, productPublished: req.body.productPublished == 'true', - productPermalink: req.body.productPermalink + productPermalink: req.body.productPermalink, + owner: req.session.peerplaysAccountId }; if(req.file) { @@ -180,7 +265,7 @@ router.post('/customer/product/insert', upload.single("productImage"), async (re let peerplaysResult = null; try{ - peerplaysResult = await new PeerplaysService().sendOperations(body, req.session.peerIDAccessToken); + peerplaysResult = await peerplaysService.sendOperations(body, req.session.peerIDAccessToken); } catch(ex) { res.status(400).json({ message: ex.message }); return; @@ -207,6 +292,151 @@ router.post('/customer/product/insert', upload.single("productImage"), async (re } }); +// mint new product form action +router.post('/customer/product/mint', async (req, res) => { + if(!req.session.peerplaysAccountId){ + return res.status(400).json({ + message: 'You need to be logged in to Mint NFT' + }); + } + + const db = req.app.db; + + const product = await db.products.findOne({ _id: getId(req.body.productId) }); + + if(!product) { + return res.status(400).json({ + message: 'Product not found' + }); + } + + const operations = []; + + for(let i = 0; i < req.body.quantity; i++) { + operations.push({ + op_name: 'nft_mint', + fee_asset: config.peerplaysAssetID, + payer: req.session.peerplaysAccountId, + nft_metadata_id: product.nftMetadataID, + owner: req.session.peerplaysAccountId, + approved: req.session.peerplaysAccountId, + approved_operators: [], + token_uri: '/' + }); + } + + const body = {operations}; + + try{ + const peerplaysResult = await peerplaysService.sendOperations(body, req.session.peerIDAccessToken); + res.status(200).json({ + message: 'NFT Minted Successfully', + NFTId: peerplaysResult.result.trx.operation_results[0][1] + }); + } catch(ex) { + console.error(ex); + res.status(400).json({ message: 'Error minting NFT' }); + return; + } +}); + +// sell new product form action +router.post('/customer/product/sell', async (req, res) => { + if(!req.session.peerplaysAccountId){ + return res.status(400).json({ + message: 'You need to be logged in to Sell NFT' + }); + } + + const db = req.app.db; + + const product = await db.products.findOne({ _id: getId(req.body.productId) }); + + if(!product) { + return res.status(400).json({ + message: 'Product not found' + }); + } + + let minted = [], sellOffers = [], availableNFTs = []; + + try { + minted = await peerplaysService.getBlockchainData({ + api: "database", + method: "nft_get_all_tokens", + "params[0]": product.owner + }); + + sellOffers = await getSellOffers(); + sellOffers = sellOffers ? sellOffers.filter((s) => s.nft_metadata_ids.includes(product.nftMetadataID)) : []; + const sellOffersCount = sellOffers.reduce((sum, s) => sum + s.item_ids.length, 0); + + minted = minted ? minted.result.filter((m) => m.nft_metadata_id === product.nftMetadataID) : []; + + if(Number(req.body.quantity) === 0) { + return res.status(400).json({ + message: 'Quantity cannot be zero' + }); + } + + if(Number(req.body.minPrice) <= 0 && Number(req.body.maxPrice) <= 0) { + return res.status(400).json({ + message: 'Price cannot be zero' + }); + } + + if(Number(req.body.quantity) > minted.length - sellOffersCount) { + return res.status(400).json({ + message: `Trying to sell ${req.body.quantity} NFTs out of ${minted.length - sellOffersCount} minted NFTs. Please mint more NFTs.` + }); + } + + if(Date.parse(req.body.expirationDate) <= Date.now()) { + return res.status(400).json({ + message: 'Sale end date cannot be less than current date and time' + }); + } + + const sellOfferNFTIds = sellOffers.reduce((arr,offer) => arr.concat(offer.item_ids), []); + + availableNFTs = minted.filter((m) => !sellOfferNFTIds.includes(m.id)); + } catch(ex) { + console.error(ex); + return res.status(400).json({ + message: 'Error fetching data from Blockchain. Please try again later.' + }); + } + + let operations = []; + + for(let i = 0; i < Number(req.body.quantity); i++) { + operations.push({ + op_name: 'offer', + fee_asset: config.peerplaysAssetID, + item_ids: [availableNFTs[i].id], + issuer: req.session.peerplaysAccountId, + minimum_price: {amount: req.body.minPrice * Math.pow(10,config.peerplaysAssetPrecision), asset_id: config.peerplaysAssetID}, + maximum_price: {amount: req.body.maxPrice * Math.pow(10,config.peerplaysAssetPrecision), asset_id: config.peerplaysAssetID}, + buying_item: false, + offer_expiration_date: Math.floor(Date.parse(req.body.expirationDate)/1000) + }); + } + + const body = {operations}; + + try{ + const peerplaysResult = await peerplaysService.sendOperations(body, req.session.peerIDAccessToken); + res.status(200).json({ + message: 'Sell offer created successfully', + NFTId: peerplaysResult.result.trx.operation_results[0][1] + }); + } catch(ex) { + console.error(ex); + res.status(400).json({ message: 'Error creating sell offer for NFT' }); + return; + } +}); + // render the editor router.get('/customer/product/edit/:id', async (req, res) => { const db = req.app.db; diff --git a/views/layouts/layout.hbs b/views/layouts/layout.hbs index d5d523c0..294b6038 100644 --- a/views/layouts/layout.hbs +++ b/views/layouts/layout.hbs @@ -293,6 +293,8 @@ {{> partials/variantEditModal}} {{/if}} {{> partials/confirmModal}} + {{> partials/nftMintModal}} + {{> partials/sellNFTModal}} {{#if @root.config.modules.enabled.reviews}} {{> partials/reviewModal}} {{/if}} diff --git a/views/partials/menu.hbs b/views/partials/menu.hbs index a0ac8885..282f9b1d 100644 --- a/views/partials/menu.hbs +++ b/views/partials/menu.hbs @@ -55,9 +55,9 @@ {{/if}} {{else}}