diff --git a/config.json.example b/config.json.example index a9e7d5f4ac..c60f7c20e9 100644 --- a/config.json.example +++ b/config.json.example @@ -31,7 +31,12 @@ "secretAccessKey":"secretAccessKey" }, "PAYPAL":{ - "billing_plan_id":"billing_plan_id", + "billing_plans": { + "1":"1", + "3":"3", + "6":"6", + "12":"12" + }, "mode":"sandbox", "client_id":"client_id", "client_secret":"client_secret" diff --git a/migrations/20141117_consecutive_months.js b/migrations/20141117_consecutive_months.js new file mode 100644 index 0000000000..312e6734de --- /dev/null +++ b/migrations/20141117_consecutive_months.js @@ -0,0 +1,14 @@ +// require moment, lodash +db.users.find( + {'purchased.plan.customerId':{$ne:null}}, + {_id:1, 'purchased.plan':1} +).forEach(function(user){ + var p = user.purchased.plan + , latestMonth = p.dateTerminated || new Date() // their last sub date, or on-going (now) + , count = moment(latestMonth).diff(p.dateCreated, 'months'); + db.users.update({_id: user._id}, {$set: { + 'purchased.plan.consecutive.count': count, + 'purchased.plan.consecutive.gemCapExtra': _.min([ Math.floor(count/3)*5, 25 ]), + 'purchased.plan.consecutive.trinkets': Math.floor(count/3) + }}); +}); diff --git a/public/css/helpers.styl b/public/css/helpers.styl index e827803593..52d245a3fb 100644 --- a/public/css/helpers.styl +++ b/public/css/helpers.styl @@ -29,4 +29,7 @@ // Hide visually and from screenreaders, but maintain layout .invisible - visibility: hidden \ No newline at end of file + visibility: hidden + +.transparent + opacity: 0.5 \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index adbcd0f6f8..c12ba3aa08 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -2,7 +2,7 @@ window.habitrpg = angular.module('habitrpg', ['ngResource', 'ngSanitize', 'userServices', 'groupServices', 'memberServices', 'challengeServices', - 'authServices', 'notificationServices', 'guideServices', 'authCtrl', + 'authServices', 'notificationServices', 'guideServices', 'authCtrl', 'paymentServices', 'ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'infinite-scroll', 'ui.select2', 'angular.filter']) // @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705 @@ -181,6 +181,10 @@ window.habitrpg = angular.module('habitrpg', url: '/equipment', templateUrl: "partials/options.inventory.equipment.html" }) + .state('options.inventory.timetravelers', { + url: '/timetravelers', + templateUrl: "partials/options.inventory.timetravelers.html" + }) // Options > Settings .state('options.settings', { diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 69c09aa938..9f433fdf37 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -2,6 +2,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification', function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) { + $scope.isMemberOfPendingQuest = function(userid, group) { if (!group.quest || !group.quest.members) return false; if (group.quest.active) return false; // quest is started, not pending @@ -125,6 +126,18 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' $scope.$close(); }); } + $scope.gift = { + type: 'gems', + gems: {amount:0, fromBalance:true}, + subscription: {months:1}, + message:'' + }; + $scope.sendGift = function(uuid, gift){ + $http.post('/api/v2/members/'+uuid+'/gift', gift).success(function(){ + Notification.text('Gift sent!') + $rootScope.User.sync(); + }) + } } ]) diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 4e27c81b52..004660dd6a 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -3,8 +3,8 @@ /* Make user and settings available for everyone through root scope. */ -habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', - function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService) { +habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$http', '$state', '$stateParams', 'Notification', 'Groups', 'Shared', 'Content', '$modal', '$timeout', 'ApiUrlService', 'Payments', + function($scope, $rootScope, $location, User, $http, $state, $stateParams, Notification, Groups, Shared, Content, $modal, $timeout, ApiUrlService, Payments) { var user = User.user; var initSticky = _.once(function(){ @@ -33,6 +33,7 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.Math = Math; $rootScope.Groups = Groups; $rootScope.toJson = angular.toJson; + $rootScope.Payments = Payments; // Angular UI Router $rootScope.$state = $state; @@ -139,53 +140,9 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.flash[type].splice($index, 1); } - $rootScope.showStripe = function(subscription) { - StripeCheckout.open({ - key: window.env.STRIPE_PUB_KEY, - address: false, - amount: 500, - name: subscription ? window.env.t('subscribe') : window.env.t('checkout'), - description: subscription ? - window.env.t('buySubsText') : - window.env.t('donationDesc'), - panelLabel: subscription ? window.env.t('subscribe') : window.env.t('checkout'), - token: function(data) { - var url = '/stripe/checkout'; - if (subscription) url += '?plan=basic_earned'; - $scope.$apply(function(){ - $http.post(url, data).success(function() { - window.location.reload(true); - }).error(function(data) { - alert(data.err); - }); - }) - } - }); - } - - $rootScope.showStripeEdit = function(){ - StripeCheckout.open({ - key: window.env.STRIPE_PUB_KEY, - address: false, - name: 'Update', - description: 'Update the card to be charged for your subscription', - panelLabel: 'Update Card', - token: function(data) { - var url = '/stripe/subscribe/edit?plan=basic_earned'; - $scope.$apply(function(){ - $http.post(url, data).success(function() { - window.location.reload(true); - }).error(function(data) { - alert(data.err); - }); - }) - } - }); - } - - $rootScope.cancelSubscription = function(){ - if (!confirm(window.env.t('sureCancelSub'))) return; - window.location.href = '/' + user.purchased.plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + user._id + '&apiToken=' + user.apiToken; + $rootScope.encodeGift = function(uuid, gift){ + gift.uuid = uuid; + return JSON.stringify(gift); } $scope.contribText = function(contrib, backer){ diff --git a/public/js/services/paymentServices.js b/public/js/services/paymentServices.js new file mode 100644 index 0000000000..7b7f5baf9e --- /dev/null +++ b/public/js/services/paymentServices.js @@ -0,0 +1,73 @@ +'use strict'; + +angular.module('paymentServices',[]).factory('Payments', +['$rootScope', 'User', '$http', 'Content', +function($rootScope, User, $http, Content) { + var user = User.user; + var plan = User.user.purchased.plan; + var Payments = {}; + + Payments.currentSub = _.find(Content.subscriptionBlocks, function(b){ + switch (plan.paymentMethod) { + case 'Stripe': + case 'Paypal': // FIXME store paypalKey somewhere? + return b.key == plan.planId; + default: return undefined; + } + }) + + Payments.showStripe = function(data) { + var sub = + data.subscription ? data.subscription + : data.gift && data.gift.type=='subscription' ? data.gift.subscription.months + : false; + sub = sub && Content.subscriptionBlocks[sub]; + var amount = // 500 = $5 + sub ? sub.price*100 + : data.gift && data.gift.type=='gems' ? data.gift.gems.amount/4*100 + : 500; + StripeCheckout.open({ + key: window.env.STRIPE_PUB_KEY, + address: false, + amount: amount, + name: sub ? window.env.t('subscribe') : window.env.t('checkout'), + //description: sub ? window.env.t('buySubsText') : window.env.t('donationDesc'), + panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), + token: function(res) { + var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below + if (data.gift) url += '&gift=' + $rootScope.encodeGift(data.uuid, data.gift); + if (data.subscription) url += '&sub='+sub.months; + $http.post(url, res).success(function() { + window.location.reload(true); + }).error(function(res) { + alert(res.err); + }); + } + }); + } + + Payments.showStripeEdit = function(){ + StripeCheckout.open({ + key: window.env.STRIPE_PUB_KEY, + address: false, + name: 'Update', + description: 'Update the card to be charged for your subscription', + panelLabel: 'Update Card', + token: function(data) { + var url = '/stripe/subscribe/edit'; + $http.post(url, data).success(function() { + window.location.reload(true); + }).error(function(data) { + alert(data.err); + }); + } + }); + } + + Payments.cancelSubscription = function(){ + if (!confirm(window.env.t('sureCancelSub'))) return; + window.location.href = '/' + plan.paymentMethod.toLowerCase() + '/subscribe/cancel?_id=' + user._id + '&apiToken=' + user.apiToken; + } + + return Payments; +}]); diff --git a/public/manifest.json b/public/manifest.json index 2e8a5b4ee1..dd4e9abe2a 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -46,6 +46,7 @@ "js/services/memberServices.js", "js/services/guideServices.js", "js/services/challengeServices.js", + "js/services/paymentServices.js", "js/filters/filters.js", diff --git a/src/controllers/members.js b/src/controllers/members.js index 9d3017960f..b26ddbabf5 100644 --- a/src/controllers/members.js +++ b/src/controllers/members.js @@ -6,45 +6,94 @@ var async = require('async'); var _ = require('lodash'); var shared = require('habitrpg-shared'); +var fetchMember = function(uuid, restrict){ + return function(cb){ + var q = User.findById(uuid); + if (restrict) q.select(partyFields); + q.exec(function(err, member){ + if (err) return cb(err); + if (!member) return cb({code:404, err: 'User not found'}); + return cb(null, member); + }) + } +} + +var sendErr = function(err, res, next){ + err.code ? res.json(err.code, {err: err.err}) : next(err); +} + api.getMember = function(req, res, next) { - User.findById(req.params.uuid).select(partyFields).exec(function(err, user){ - if (err) return next(err); - if (!user) return res.json(400,{err:'User not found'}); - res.json(user); + fetchMember(req.params.uuid, true)(function(err, member){ + if (err) return sendErr(err, res, next); + res.json(member); }) } -api.sendPrivateMessage = function(req,res,next){ +api.sendMessage = function(user, member, data){ + var msg; + if (!data.type) { + msg = data.message + } else { + msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you "; + msg += (data.type=='gems') ? data.gems.amount + " gems!`" : data.subscription.months + " months of subscription!`"; + msg += data.message; + } + shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user)); + member.inbox.newMessages++; + member._v++; + member.markModified('inbox.messages'); + + shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member))); + user.markModified('inbox.messages'); +} + +api.sendPrivateMessage = function(req, res, next){ async.waterfall([ - function(cb){ - User.findById(req.params.uuid, cb); - }, - function(member, cb){ - if (!member) return cb({code:404, err: 'User not found'}); + fetchMember(req.params.uuid), + function(member, cb) { if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me || ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them || member.inbox.optOut) { // or if they've opted out of messaging - return cb({code:401, err: "Can't send message to this user."}); + return cb({code: 401, err: "Can't send message to this user."}); } - - var message = groups.chatDefaults(req.body.message, res.locals.user); - shared.refPush(member.inbox.messages, message); - member.inbox.newMessages++; - member._v++; - member.markModified('inbox.messages'); - - var message = groups.chatDefaults(req.body.message, member); - shared.refPush(res.locals.user.inbox.messages, _.defaults({sent:true},message)); - res.locals.user.markModified('inbox.messages'); - - member.save(cb); - }, - function(a,b,cb){ - res.locals.user.save(cb); + api.sendMessage(res.locals.user, member, {message:req.body.message}); + async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { res.locals.user.save(cb2) } + ], cb); } ], function(err){ - if (err) return err.code ? res.json(err.code,{err:err.err}) : err; + if (err) return sendErr(err, res, next); res.send(200); }) +} +api.sendGift = function(req, res, next){ + async.waterfall([ + fetchMember(req.params.uuid), + function(member, cb) { + // Gems + switch (req.body.type) { + case "gems": + var amt = req.body.gems.amount / 4, + user = res.locals.user; + if (!amt || amt < 1 || user.balance < amt) + return cb({code: 401, err: "Amount must be within 0 and your current number of gems."}); + member.balance += amt; + user.balance -= amt; + api.sendMessage(user, member, req.body); + return async.parallel([ + function (cb2) { member.save(cb2) }, + function (cb2) { user.save(cb2) } + ], cb); + case "subscription": + return cb(); + default: + return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"}); + } + } + ], function(err) { + if (err) return sendErr(err, res, next); + res.send(200); + }); } \ No newline at end of file diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index ba6359d4f9..be5fdff248 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -7,13 +7,16 @@ var moment = require('moment'); var isProduction = nconf.get("NODE_ENV") === "production"; var stripe = require('./stripe'); var paypal = require('./paypal'); +var User = require('mongoose').model('User'); +var members = require('../members') +var async = require('async'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { if ( item.klass === 'mystery' && - moment().isAfter(item.mystery.start) && - moment().isBefore(item.mystery.end) && + moment().isAfter(shared.content.mystery[item.mystery].start) && + moment().isBefore(shared.content.mystery[item.mystery].end) && !user.items.gear.owned[item.key] && !~user.purchased.plan.mysteryItems.indexOf(item.key) ) { @@ -22,47 +25,93 @@ function revealMysteryItems(user) { }); } -exports.createSubscription = function(user, data) { - if (!user.purchased.plan) user.purchased.plan = {}; - _(user.purchased.plan) - .merge({ // override with these values - planId:'basic_earned', +exports.createSubscription = function(data, cb) { + var recipient = data.gift ? data.gift.member : data.user; + //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case + var p = recipient.purchased.plan; + var months = data.gift ? data.gift.subscription.months : data.sub.months; + var block = shared.content.subscriptionBlocks[months]; + + if (data.gift) { + if (p.customerId && !p.dateTerminated) { // User has active plan + p.extraMonths += +months; + } else { + p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); + } + if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + } else { + _(p).merge({ // override with these values + planId: block.key, customerId: data.customerId, dateUpdated: new Date(), gemsBought: 0, paymentMethod: data.paymentMethod, + extraMonths: +p.extraMonths + + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), dateTerminated: null - }) - .defaults({ // allow non-override if a plan was previously used + }).defaults({ // allow non-override if a plan was previously used dateCreated: new Date(), mysteryItems: [] }); - revealMysteryItems(user); - if(isProduction) utils.txnEmail(user, 'subscription-begins'); - user.purchased.txnCount++; - utils.ga.event('subscribe', data.paymentMethod).send(); - utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); + } + + // Block sub perks + var perks = Math.floor(months/3); + if (perks) { + p.consecutive.offset += months; + p.consecutive.gemCapExtra += perks*5; + if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; + p.consecutive.trinkets += perks; + } + revealMysteryItems(recipient); + if(isProduction) { + data.gift && utils.txnEmail(data.user, 'subscription-begins'); + utils.ga.event('subscribe', data.paymentMethod).send(); + utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); + } + data.user.purchased.txnCount++; + if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); } /** * Sets their subscription to be cancelled later */ -exports.cancelSubscription = function(user, data) { - var du = user.purchased.plan.dateUpdated, now = moment(); - if(isProduction) utils.txnEmail(user, 'cancel-subscription'); - user.purchased.plan.dateTerminated = - moment( now.format('MM') + '/' + moment(du).format('DD') + '/' + now.format('YYYY') ) - .add({months:1}) +exports.cancelSubscription = function(data, cb) { + var p = data.user.purchased.plan, + now = moment(), + remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; + + p.dateTerminated = + moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) + .add({days: remaining}) // end their subscription 1mo from their last payment + .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... .toDate(); - utils.ga.event('unsubscribe', 'Stripe').send(); + p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated + + data.user.save(cb); + if(isProduction) utils.txnEmail(data.user, 'cancel-subscription'); + utils.ga.event('unsubscribe', data.paymentMethod).send(); } -exports.buyGems = function(user, data) { - user.balance += 5; - user.purchased.txnCount++; - if(isProduction) utils.txnEmail(user, 'donation'); - utils.ga.event('checkout', data.paymentMethod).send(); - utils.ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); +exports.buyGems = function(data, cb) { + var amt = data.gift ? data.gift.gems.amount/4 : 5; + (data.gift ? data.gift.member : data.user).balance += amt; + data.user.purchased.txnCount++; + if(isProduction) { + utils.txnEmail(data.user, 'donation'); + utils.ga.event('checkout', data.paymentMethod).send(); + //TODO ga.transaction to reflect whether this is gift or self-purchase + utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); + } + if (data.gift) members.sendMessage(data.user, data.gift.member, data.gift); + async.parallel([ + function(cb2){data.user.save(cb2)}, + function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} + ], cb); } exports.stripeCheckout = stripe.checkout; diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js index cfc0b8d4d2..25195b5695 100644 --- a/src/controllers/payments/paypal.js +++ b/src/controllers/payments/paypal.js @@ -3,16 +3,19 @@ var moment = require('moment'); var async = require('async'); var _ = require('lodash'); var url = require('url'); -var mongoose = require('mongoose'); +var User = require('mongoose').model('User'); var payments = require('./index'); var logger = require('../../logging'); var ipn = require('paypal-ipn'); var paypal = require('paypal-rest-sdk'); +var shared = require('habitrpg-shared'); // This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have // a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created // there, get it's plan.id and store it in config.json -var billingPlanID = nconf.get('PAYPAL:billing_plan_id'); +_.each(shared.content.subscriptionBlocks, function(block){ + block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.months); +}); paypal.configure({ 'mode': nconf.get("PAYPAL:mode"), //sandbox or live @@ -20,25 +23,27 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var parseErr = function(err){ - return (err.response && err.response.message || err.response.details[0].issue) || err; +var parseErr = function(res, err){ + var error = err.response ? err.response.message || err.response.details[0].issue : err; + return res.json(400,{err:error}); } exports.createBillingAgreement = function(req,res,next){ - var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; + var block = req.session.paypalBlock = shared.content.subscriptionBlocks[req.query.sub]; + var billingPlanTitle = "HabitRPG Subscription" + ' ($'+block.price+' every '+block.months+' months, recurring)'; var billingAgreementAttributes = { "name": billingPlanTitle, "description": billingPlanTitle, - "start_date": moment().add({seconds:5}).format(), + "start_date": moment().add({minutes:5}).format(), "plan": { - "id": billingPlanID + "id": block.paypalKey }, "payer": { "payment_method": "paypal" } }; paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); // For approving subscription via Paypal, first redirect user to: approval_url var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; res.redirect(approval_url); @@ -51,22 +56,30 @@ exports.executeBillingAgreement = function(req,res,next){ paypal.billingAgreement.execute(req.query.token, {}, cb); }, function(billingAgreement, cb){ - mongoose.model('User').findById(req.session.userId, function(err, user){ + User.findById(req.session.userId, function(err, user){ if (err) return cb(err); cb(null, {billingAgreement:billingAgreement, user:user}); }); }, function(data, cb){ - payments.createSubscription(data.user, {customerId: data.billingAgreement.id, paymentMethod: 'Paypal'}); + payments.createSubscription({user:data.user, customerId: data.billingAgreement.id, paymentMethod: 'Paypal', sub:req.session.paypalBlock}); data.user.save(cb); } ],function(err){ - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); res.redirect('/'); }) } exports.createPayment = function(req, res, next) { + // if we're gifting to a user, put it in session for the `execute()` + var gift = req.session.gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var price = !gift ? 5.00 + : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) + : Number(shared.content.subscriptionBlocks[gift.subscription.months].price).toFixed(2); + var description = !gift ? "HabitRPG Gems" + : gift.type=='gems' ? "HabitRPG Gems (Gift)" + : gift.subscription.months + "mo. HabitRPG Subscription (Gift)"; var create_payment = { "intent": "sale", "payer": { @@ -79,22 +92,22 @@ exports.createPayment = function(req, res, next) { "transactions": [{ "item_list": { "items": [{ - "name": "HabitRPG Gems", + "name": description, //"sku": "1", - "price": "5.00", + "price": price, "currency": "USD", "quantity": 1 }] }, "amount": { "currency": "USD", - "total": "5.00" + "total": price }, - "description": "HabitRPG Gems" + "description": description }] }; paypal.payment.create(create_payment, function (err, payment) { - if (err) return next(parseErr(err)); + if (err) return parseErr(res, err); var link = _.find(payment.links, {rel: 'approval_url'}).href; res.redirect(link); }); @@ -102,21 +115,31 @@ exports.createPayment = function(req, res, next) { exports.executePayment = function(req, res, next) { var paymentId = req.query.paymentId, - PayerID = req.query.PayerID; + PayerID = req.query.PayerID, + gift = req.session.gift; async.waterfall([ function(cb){ paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); }, function(payment, cb){ - mongoose.model('User').findById(req.session.userId, cb); + async.parallel([ + function(cb2){ User.findById(req.session.userId, cb2); }, + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } + ], cb); }, - function(user, cb){ - if (_.isEmpty(user)) return cb("user not found when completing paypal transaction"); - payments.buyGems(user, {customerId:PayerID, paymentMethod:'Paypal'}); - user.save(cb); + function(results, cb){ + if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); + var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} + var method = 'buyGems'; + if (gift) { + gift.member = results[1]; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb); } - ],function(err, saved){ - if (err) return next(parseErr(err)); + ],function(err){ + if (err) return parseErr(res, err); res.redirect('/'); }) } @@ -125,16 +148,25 @@ exports.cancelSubscription = function(req, res, next){ var user = res.locals.user; if (!user.purchased.plan.customerId) return res.json(401, {err: "User does not have a plan subscription"}); - async.waterfall([ - function(cb) { - paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + async.auto({ + get_cus: function(cb){ + paypal.billingAgreement.get(user.purchased.plan.customerId, cb); }, - function(response, cb) { - payments.cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return next(parseErr(err)); + verify_cus: ['get_cus', function(cb, results){ + var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; + if (hasntBilledYet) + return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); + cb(); + }], + del_cus: ['verify_cus', function(cb, results){ + paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); + }], + cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ + var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; + payments.cancelSubscription(data, cb) + }] + }, function(err){ + if (err) return parseErr(res, err); res.redirect('/'); user = null; }); @@ -153,11 +185,10 @@ exports.ipn = function(req, res, next) { // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... case 'recurring_payment_profile_cancel': case 'subscr_cancel': - mongoose.model('User').findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ + User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ if (err) return logger.error(err); if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - payments.cancelSubscription(user); - user.save(); + payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); }); break; } diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js index d4d7f8e167..c286313ae5 100644 --- a/src/controllers/payments/paypalBillingSetup.js +++ b/src/controllers/payments/paypalBillingSetup.js @@ -2,12 +2,14 @@ // payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this // file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), // and once for any time you need to edit the plan thereafter - +require('coffee-script'); var path = require('path'); var nconf = require('nconf'); +_ = require('lodash'); nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); var paypal = require('paypal-rest-sdk'); -var OP = "list"; // list create update remove +var OP = "create"; // list create update remove +var blocks = require('habitrpg-shared').content.subscriptionBlocks; paypal.configure({ 'mode': nconf.get("PAYPAL:mode"), //sandbox or live @@ -15,8 +17,8 @@ paypal.configure({ 'client_secret': nconf.get("PAYPAL:client_secret") }); -var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; // https://developer.paypal.com/docs/api/#billing-plans-and-agreements +var billingPlanTitle ="HabitRPG Subscription"; var billingPlanAttributes = { "name": billingPlanTitle, "description": billingPlanTitle, @@ -26,18 +28,23 @@ var billingPlanAttributes = { "cancel_url": nconf.get("BASE_URL"), "return_url": nconf.get('BASE_URL') + '/paypal/subscribe/success' }, - "payment_definitions": [{ - "name": billingPlanTitle, + payment_definitions: [{ "type": "REGULAR", - "frequency_interval": "1", "frequency": "MONTH", - "cycles": "0", - "amount": { - "currency": "USD", - "value": "5" - } + "cycles": "0" }] }; +_.each(blocks, function(block){ + block.definition = _.cloneDeep(billingPlanAttributes); + _.merge(block.definition.payment_definitions[0], { + "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', + "frequency_interval": ""+block.months, + "amount": { + "currency": "USD", + "value": ""+block.price + } + }); +}) switch(OP) { case "list": @@ -48,7 +55,8 @@ switch(OP) { case "update": break; case "create": - paypal.billingPlan.create(billingPlanAttributes, function(err,plan){ + paypal.billingPlan.create(blocks["12"].definition, function(err,plan){ + if (err) return console.log(err); if (plan.state == "ACTIVE") return console.log({err:err, plan:plan}); var billingPlanUpdateAttributes = [{ @@ -60,7 +68,7 @@ switch(OP) { }]; // Activate the plan by changing status to Active paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ - console.log({err:err, response:response}); + console.log({err:err, response:response, id:plan.id}); }); }); case "remove": diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js index 4f9a52afeb..117fc29175 100644 --- a/src/controllers/payments/stripe.js +++ b/src/controllers/payments/stripe.js @@ -2,6 +2,8 @@ var nconf = require('nconf'); var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); var async = require('async'); var payments = require('./index'); +var User = require('mongoose').model('User'); +var shared = require('habitrpg-shared'); /* Setup Stripe response when posting payment @@ -9,33 +11,45 @@ var payments = require('./index'); exports.checkout = function(req, res, next) { var token = req.body.id; var user = res.locals.user; + var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; + var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; async.waterfall([ function(cb){ - if (req.query.plan) { + if (sub) { stripe.customers.create({ email: req.body.email, - metadata: {uuid: res.locals.user._id}, + metadata: {uuid: user._id}, card: token, - plan: req.query.plan + plan: sub.key }, cb); } else { stripe.charges.create({ - amount: "500", // $5 + amount: !gift ? "500" //"500" = $5 + : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.months].price*100 + : ""+gift.gems.amount/4*100, currency: "usd", card: token }, cb); } }, function(response, cb) { - if (req.query.plan) { - payments.createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } else { - payments.buyGems(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } - user.save(cb); + if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); + async.waterfall([ + function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, + function(member, cb2){ + var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; + var method = 'buyGems'; + if (gift) { + gift.member = member; + if (gift.type=='subscription') method = 'createSubscription'; + data.paymentMethod = 'Gift'; + } + payments[method](data, cb2); + } + ], cb); } - ], function(err, saved){ + ], function(err){ if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.send(200); user = token = null; @@ -47,15 +61,22 @@ exports.subscribeCancel = function(req, res, next) { if (!user.purchased.plan.customerId) return res.json(401, {err: "User does not have a plan subscription"}); - async.waterfall([ - function(cb) { - stripe.customers.del(user.purchased.plan.customerId, cb); + async.auto({ + get_cus: function(cb){ + stripe.customers.retrieve(user.purchased.plan.customerId, cb); }, - function(response, cb) { - payments.cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ + del_cus: ['get_cus', function(cb, results){ + stripe.customers.del(user.purchased.plan.customerId, cb); + }], + cancel_sub: ['get_cus', function(cb, results) { + var data = { + user: user, + nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds + paymentMethod: 'Stripe' + }; + payments.cancelSubscription(data, cb); + }] + }, function(err, results){ if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors res.redirect('/'); user = null; diff --git a/src/models/group.js b/src/models/group.js index fd2518b8ed..1bc275afe4 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -93,10 +93,10 @@ GroupSchema.methods.toJSON = function(){ return doc; } -var chatDefaults = module.exports.chatDefaults = function(message,user){ +var chatDefaults = module.exports.chatDefaults = function(msg,user){ var message = { id: shared.uuid(), - text: message, + text: msg, timestamp: +new Date, likes: {} }; @@ -105,7 +105,7 @@ var chatDefaults = module.exports.chatDefaults = function(message,user){ uuid: user._id, contributor: user.contributor && user.contributor.toObject(), backer: user.backer && user.backer.toObject(), - user: user.profile.name, + user: user.profile.name }); } else { message.uuid = 'system'; diff --git a/src/models/user.js b/src/models/user.js index 4aef97d59c..3dbb7f0f29 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -92,13 +92,20 @@ var UserSchema = new Schema({ mobileChat: Boolean, plan: { planId: String, - paymentMethod: String, //enum: ['Paypal','Stripe', '']} + paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']} customerId: String, dateCreated: Date, dateTerminated: Date, dateUpdated: Date, + extraMonths: {type:Number, 'default':0}, gemsBought: {type: Number, 'default': 0}, - mysteryItems: {type: Array, 'default': []} + mysteryItems: {type: Array, 'default': []}, + consecutive: { + count: {type:Number, 'default':0}, + offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 + gemCapExtra: {type:Number, 'default':0}, + trinkets: {type:Number, 'default':0} + } } }, diff --git a/src/routes/apiv2.coffee b/src/routes/apiv2.coffee index 00191a52b1..415577d565 100644 --- a/src/routes/apiv2.coffee +++ b/src/routes/apiv2.coffee @@ -602,6 +602,16 @@ module.exports = (swagger, v2) -> ] middleware: [auth.auth] action: user.blockUser + "/members/{uuid}/gift": + spec: + method: 'POST' + description: 'Send a gift to a member' + parameters: [ + path 'uuid', 'The UUID of the member', 'string' + body '', '{gems:{amount:Number, fromBalance:Boolean}, subscription:{months:Number}}', 'object' + ] + middleware: [auth.auth] + action: members.sendGift # --------------------------------- # Hall of Heroes / Patrons diff --git a/views/options/inventory/index.jade b/views/options/inventory/index.jade index d75bc6f317..af191f3de1 100644 --- a/views/options/inventory/index.jade +++ b/views/options/inventory/index.jade @@ -15,6 +15,9 @@ script(type='text/ng-template', id='partials/options.inventory.html') li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.equipment') }") a(ui-sref='options.inventory.equipment') =env.t('equipment') + li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.timetravelers') }") + a(ui-sref='options.inventory.timetravelers') + | Time Travelers .tab-content .tab-pane.active diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 05b8bde9ab..5fed87ba1d 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -19,6 +19,37 @@ script(type='text/ng-template', id='partials/options.inventory.equipment.html') div(ng-repeat='item in gear[klass]') button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='user.ops.equip({params:{type:"costume", key:item.key}})', class='shop_{{::item.key}}', ng-class='{selectableInventory: user.items.gear.costume[item.type] == item.key}') +script(type='text/ng-template', id='partials/options.inventory.timetravelers.html') + .container-fluid + .stable.row(ng-if='user.purchased.plan.consecutive.trinkets <= 0') + .col-md-2 + .npc_timetravelers + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title Tyler and Vicky + .popover-content + p You'll need a Mystic Hourglass to summon the mysterious Time Travelers! Subscribers earn one Mystic Hourglass for every three months of consecutive subscribing. Come back when you have a Mystic Hourglass, and the Time Travelers will fetch you a Subscriber Item Set from the past.... or maybe even the future. + + .row.stable(ng-if='user.purchased.plan.consecutive.trinkets > 0') + .col-md-2 + .npc_timetravelers_active + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title Mysterious Time Travelers + .popover-content + .pull-right + span.inventory_special_trinket.inline-gems + b x{{user.purchased.plan.consecutive.trinkets}} + p We see you have a Mystic Hourglass, so we will happily travel back in time for you! Please choose the Mystery Item Set you would like. You can see a list of the past item sets here! If those don't satisfy you, perhaps you'd be interested in one of our fashionably futuristic Steampunk Item Sets? + + .col-md-12 + li.customize-menu.inventory-gear + menu.pets-menu(label='{{::set.text}}', ng-repeat='set in Content.timeTravelerStore(user.items.gear.owned)') + div(ng-repeat='item in set.items') + button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='user.ops.buyMysterySet({params:{key:set.key}})', class='shop_{{::item.key}}') + script(type='text/ng-template', id='partials/options.inventory.drops.html') .container-fluid .row @@ -78,6 +109,10 @@ script(type='text/ng-template', id='partials/options.inventory.drops.html') button.customize-option(popover=env.t('subscriberItemText'), popover-trigger='mouseenter', popover-placement='right', class='inventory_present', ng-click="user.ops.openMysteryItem({})") .badge.badge-info.stack-count {{user.purchased.plan.mysteryItems.length}} + div(ng-if='user.purchased.plan.consecutive.trinkets') + button.customize-option(popover="Mystic Hourglass allow you to purchase previous months' subscriber sets.", popover-trigger='mouseenter', popover-placement='right', class='inventory_special_trinket', ng-click="$state.go('options.inventory.timetravelers')") + .badge.badge-info.stack-count {{user.purchased.plan.consecutive.trinkets}} + .col-md-6 h2=env.t('market') .row-fluid diff --git a/views/options/settings.jade b/views/options/settings.jade index 99c94f4dc2..ef2727b4f6 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -192,14 +192,15 @@ script(id='partials/options.settings.export.html', type="text/ng-template") a(href="/export/userdata.xml")= ' ' + env.t('xml') + ' ' a(href="/export/userdata.json")= env.t('json') -script(id='partials/options.settings.subscription.perks.html',type='text/ng-template') +mixin subPerks() table.table.table-striped tr td span.hint(popover=env.t('disableAdsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('disableAds') tr td - span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right')=env.t('buyGemsGold') + span.hint(popover=env.t('buyGemsGoldText', {gemCost: "{{Shared.planGemLimits.convRate}}", gemLimit: "{{Shared.planGemLimits.convCap}}"}),popover-trigger='mouseenter',popover-placement='right') #{env.t('buyGemsGold')}  + span.badge.badge-success(ng-show='_subscription.months>1') Cap raised to {{ [25 + user.purchased.plan.consecutive.gemCapExtra + Math.floor(_subscription.months/3*5), 50] | min }} tr td span.hint(popover=env.t('retainHistoryText'),popover-trigger='mouseenter',popover-placement='right')=env.t('retainHistory') @@ -208,13 +209,13 @@ script(id='partials/options.settings.subscription.perks.html',type='text/ng-temp span.hint(popover=env.t('doubleDropsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('doubleDrops') tr td - span.hint(popover=env.t('mysteryItemText'),popover-trigger='mouseenter',popover-placement='right')=env.t('mysteryItem') + span.hint(popover=env.t('mysteryItemText'),popover-trigger='mouseenter',popover-placement='right') #{env.t('mysteryItem')}  + div(ng-show='_subscription.months>1') + .badge.badge-success +{{Math.floor(_subscription.months/3)}} Mystic Hourglass + .small.muted Mystic Hourglasses allow purchasing a previous month's Mystery Item set. tr td span.hint(popover=env.t('supportDevsText'),popover-trigger='mouseenter',popover-placement='right')=env.t('supportDevs') - tr - td.alert.alert-info $5  - =env.t('monthUSD') script(id='partials/feature-matrix-check.html',type='text/ng-template') span.task-checker.action-yesno @@ -222,21 +223,43 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') label script(id='partials/options.settings.subscription.html',type='text/ng-template') - .well(ng-init="p = user.purchased.plan") - div(ng-if='p.customerId') - p.alert.alert-warning(ng-if='p.dateTerminated') - i.glyphicon.glyphicon-time - | #{env.t('subCanceled')} {{moment(p.dateTerminated).format('MM/DD/YYYY')}} - p.alert.alert-success(ng-if='!p.dateTerminated')=env.t('subscribed') + //-h2=env.t('individualSub') + .container-fluid(ng-init='_subscription={months:1}') + .row + .col-md-6 + h3 Benefits + +subPerks() - h2=env.t('individualSub') - div(ng-include="'partials/options.settings.subscription.perks.html'") + .col-md-6 + table.table.alert.alert-info(ng-if='user.purchased.plan.customerId') + tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning + i.glyphicon.glyphicon-time + | #{env.t('subCanceled')} {{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} + tr(ng-if='!user.purchased.plan.dateTerminated'): td + h4=env.t('subscribed') + p(ng-if='Payments.currentSub') Recurring ${{Payments.currentSub.price}} each {{Payments.currentSub.months}} Month(s) ({{user.purchased.plan.paymentMethod}}) + tr(ng-if='user.purchased.plan.extraMonths'): td + span.glyphicon.glyphicon-credit-card + | You have {{user.purchased.plan.extraMonths | number:2}} months of subscription credit. + tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td + span.glyphicon.glyphicon-forward + |  Consecutive Subscription + ul.list-unstyled + li Consecutive Months: {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}} + li Gem Cap Extra: {{user.purchased.plan.consecutive.gemCapExtra}} + li Mystic Hourglasses: {{user.purchased.plan.consecutive.trinkets}} + div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') + .form-group + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='_subscription.months') + //-| #{block.months} Month(s) Recurring: $#{block.price} #{env.t('monthUSD')} + | Recurring $#{block.price} each #{block.months} Month(s) - div(ng-if='!p.customerId || (p.customerId && p.dateTerminated)') - h3(ng-if='(p.customerId && p.dateTerminated)') Resubscribe - a.btn.btn-primary(ng-click='showStripe(true)') Card - //a.btn.btn-warning(ng-click='paypalSubscribe()') PayPal - a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal - div(ng-if='p.customerId') - .btn.btn-primary(ng-if='!p.dateTerminated && p.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if='!p.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') + h3(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)') Resubscribe + a.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.months})', ng-disabled='!_subscription.months') Card + a.btn.btn-warning(href='/paypal/subscribe?_id={{user._id}}&apiToken={{user.apiToken}}&sub={{_subscription.months}}', ng-disabled='!_subscription.months') PayPal + div(ng-if='user.purchased.plan.customerId') + .btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()') Update Card + .btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub') \ No newline at end of file diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 3ab0b882fc..24f4f27e16 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -47,6 +47,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.inventory.mounts')=env.t('mounts') li a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers') Time Travelers ul.toolbar-submenu li a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') @@ -126,6 +128,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(ui-sref='options.inventory.mounts')=env.t('mounts') li a(ui-sref='options.inventory.equipment')=env.t('equipment') + li + a(ui-sref='options.inventory.timetravelers') Time Travelers li.toolbar-button-dropdown a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}') span=env.t('data') diff --git a/views/shared/modals/buy-gems.jade b/views/shared/modals/buy-gems.jade index c907973f7b..8cb78cc0b2 100644 --- a/views/shared/modals/buy-gems.jade +++ b/views/shared/modals/buy-gems.jade @@ -20,8 +20,7 @@ script(id='modals/buyGems.html', type='text/ng-template') td p small.muted=env.t('paymentMethods') - //.btn.btn-primary(ng-click='showStripe()')=env.t('payWithCard') - .btn.btn-primary(ng-click='showStripe()')=env.t('card') + .btn.btn-primary(ng-click='Payments.showStripe({})')=env.t('card') a.btn.btn-warning(href='/paypal/checkout?_id={{user._id}}&apiToken={{user.apiToken}}') PayPal div(ng-include="'partials/options.settings.subscription.html'") diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index 2925d4617d..b495489078 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -38,6 +38,8 @@ script(type='text/ng-template', id='modals/member.html') span.glyphicon.glyphicon-ban-circle button.btn.btn-md.btn-default(tooltip=env.t('sendPM'), ng-click="openModal('private-message',{controller:'MemberModalCtrl'})", tooltip-placement='right') span.glyphicon.glyphicon-envelope + button.btn.btn-md.btn-default(tooltip="Send Gift", ng-click="openModal('send-gift',{controller:'MemberModalCtrl'})", tooltip-placement='right') + span.glyphicon.glyphicon-gift button.btn.btn-default(ng-click='$close()')=env.t('close') script(type='text/ng-template', id='modals/private-message.html') @@ -48,3 +50,42 @@ script(type='text/ng-template', id='modals/private-message.html') .modal-footer button.btn.btn-primary(ng-click='sendPrivateMessage(profile._id, _message)')=env.t("send") button.btn.btn-default(ng-click='$close()')=env.t('cancel') + +script(type='text/ng-template', id='modals/send-gift.html') + .modal-header + h4 Send Gift to {{::profile.profile.name}} + .modal-body + .panel.panel-default(class="{{gift.type=='gems' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="gems"') + .panel-heading + .pull-right + span(ng-show='gift.gems.fromBalance') From {{user.balance*4}} Gems + span(ng-hide='gift.gems.fromBalance') Total: ${{gift.gems.amount/4}} USD + | Gems + .panel-body + .row + .col-md-6 + .form-group + input.form-control(type='number', placeholder='Number of Gems', min='0', max='{{gift.gems.fromBalance ? user.balance*4 : ""}}', ng-model='gift.gems.amount') + .col-md-6 + .btn-group + a.btn.btn-default(ng-class="{active:gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=true") From Balance + a.btn.btn-default(ng-class="{active:!gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=false") Purchase + + .panel.panel-default(class="{{gift.type=='subscription' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="subscription"') + .panel-heading Subscription + .panel-body + .form-group + each block in env.Content.subscriptionBlocks + .radio + label + input(type="radio", name="subRadio", value="#{block.months}", ng-model='gift.subscription.months') + | #{block.months} Month(s): $#{block.price} + + textarea.form-control(rows='3', ng-model='gift.message', placeholder='Personal message (optional)') + + .modal-footer + - var fromBal = "gift.type=='gems' && gift.gems.fromBalance" + button.btn.btn-primary(ng-show=fromBal, ng-click='sendGift(profile._id, gift)')=env.t("send") + a.btn.btn-primary(ng-hide=fromBal, ng-click='Payments.showStripe({gift:gift, uuid:profile._id})')=env.t('card') + a.btn.btn-warning(ng-hide=fromBal, href='/paypal/checkout?_id={{::user._id}}&apiToken={{::user.apiToken}}&gift={{encodeGift(profile._id, gift)}}') PayPal + button.btn.btn-default(ng-click='$close()')=env.t('cancel') \ No newline at end of file