diff --git a/config.json.example b/config.json.example index d8bb67b62e..74e2f0822c 100644 --- a/config.json.example +++ b/config.json.example @@ -41,5 +41,13 @@ "mode":"sandbox", "client_id":"client_id", "client_secret":"client_secret" + }, + "IAP_GOOGLE_KEYDIR": "/path/to/google/public/key/dir/", + "LOGGLY": { + "enabled": false, + "subdomain": "subdomain", + "token": "token", + "username": "username", + "password": "password" } } diff --git a/keys/google/iap-live b/keys/google/iap-live new file mode 100644 index 0000000000..69dc38136b --- /dev/null +++ b/keys/google/iap-live @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk6EVccD7D6y5vLmsUxUXVYTMyByq+NjfTzjMBaKCk93fq8Ahn6Ipha1K4oBIvArUj3CsP5Tb/Yf6c6SrEsSnE3eqgKQgeq+fYyfIm+Fb+tFGTttLonZ2Sqemqeyx2PjOG2CVZBXj1+Zr7jb0ocMNYraYUTvklWnpcK6/5ZaZLLent1PGiU9l5jIfRAsxmJH/UpssgpFPnCs+8PRKUALkJATTfVCCF+PJef6mcKAafAArKUEo0lR2/NeaE62Wpii33DLuKDPfMqL3g4EHZTEVE1ezFl/3WPeLlxuQm38hZLpEfEYKp17qzplU+GmWIfjZt6OdtVdonlYxIxNAZLnWEQIDAQAB \ No newline at end of file diff --git a/migrations/20141211_NaN_consecutives.js b/migrations/20141211_NaN_consecutives.js new file mode 100644 index 0000000000..2822df0267 --- /dev/null +++ b/migrations/20141211_NaN_consecutives.js @@ -0,0 +1,4 @@ +db.users.update({'purchased.plan.consecutive.count':NaN}, {$set:{'purchased.plan.consecutive.count':0}}, {multi:1}); +db.users.update({'purchased.plan.consecutive.offset':NaN}, {$set:{'purchased.plan.consecutive.offset':0}}, {multi:1}); +db.users.update({'purchased.plan.consecutive.gemCapExtra':NaN}, {$set:{'purchased.plan.consecutive.gemCapExtra':0}}, {multi:1}); +db.users.update({'purchased.plan.consecutive.trinkets':NaN}, {$set:{'purchased.plan.consecutive.trinkets':0}}, {multi:1}); \ No newline at end of file diff --git a/package.json b/package.json index fc3edb043b..b6b97ba199 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "grunt-nodemon": "~0.3.0", "habitrpg-shared": "git://github.com/HabitRPG/habitrpg-shared#develop", "icalendar": "git://github.com/lefnire/node-icalendar#master", + "in-app-purchase": "^0.2.0", "jade": "~1.7.0", "js2xmlparser": "~0.1.2", "lodash": "~2.4.1", + "loggly": "~1.0.8", "method-override": "~2.2.0", "moment": "~2.8.3", "mongoose": "~3.8.17", @@ -61,7 +63,7 @@ "www.habitrpg.com" ], "engines": { - "node": "0.10.x", + "node": "0.10.22", "npm": "1.2.x" }, "scripts": { diff --git a/public/css/avatar.styl b/public/css/avatar.styl index f5132b070f..ea00c40e02 100644 --- a/public/css/avatar.styl +++ b/public/css/avatar.styl @@ -31,16 +31,17 @@ future re: pets and whatnot, this is just temporary. position: absolute @extend $hrpg-label .avatar-level - bottom: 2px - right: 2px + bottom: 4px + right: 4px // when a buff/rebirth exists, add space between the icon and the level .glyphicon-circle-arrow-up padding-right: 4px .glyphicon-plus-sign padding-left: 4px .avatar-name - top: 2px - left: 2px + max-width: 132px + top: 4px + left: 4px opacity: 0 // Hide username by default transition: opacity 0.2s ease-out @@ -83,8 +84,9 @@ future re: pets and whatnot, this is just temporary. width: 50px !important .addthis_native_toolbox position: absolute - top:2px - right:2px + bottom:4px + left:4px + z-index: 2 opacity:0 &:hover .addthis_native_toolbox opacity:1 diff --git a/public/css/game-pane.styl b/public/css/game-pane.styl index 1a002370ba..8ccc3c211d 100644 --- a/public/css/game-pane.styl +++ b/public/css/game-pane.styl @@ -37,6 +37,9 @@ markdown, span.time padding: 0 3px line-height: 1.618em + + .long-title .tooltip + white-space: normal span.time white-space: nowrap diff --git a/public/css/helpers.styl b/public/css/helpers.styl index 52d245a3fb..d9b54a3089 100644 --- a/public/css/helpers.styl +++ b/public/css/helpers.styl @@ -32,4 +32,33 @@ visibility: hidden .transparent - opacity: 0.5 \ No newline at end of file + opacity: 0.5 + +// Allow block element to be centered +.col-centered + margin-left: auto + margin-right: auto + +// .pull-right & .pull-left for specific screen sizes +// Follows conventions for col-x-SIZE classes + +@media (min-width: 768px) + .pull-left-sm + float: left + + .pull-right-sm + float: right + +@media (min-width: 992px) + .pull-left-md + float: left + + .pull-right-md + float: right + +@media (min-width: 1200px) + .pull-left-lg + float: left + + .pull-right-lg + float: right diff --git a/public/css/quests.styl b/public/css/quests.styl index fe7209f4e9..3f5fa8a79b 100644 --- a/public/css/quests.styl +++ b/public/css/quests.styl @@ -1,3 +1,7 @@ .quest_collected_true color: #ccc - text-decoration:line-through \ No newline at end of file + text-decoration:line-through + +quest-rewards + hr + clear:both diff --git a/public/css/tasks.styl b/public/css/tasks.styl index 856b7a9a5e..2f54c9365f 100644 --- a/public/css/tasks.styl +++ b/public/css/tasks.styl @@ -118,6 +118,8 @@ for $stage in $stages .habit-wide .task-text padding-left: 7em +.habit-narrow .task-text + padding-left: 0.75em // when a task is being dragged .task.ui-sortable-helper diff --git a/public/js/app.js b/public/js/app.js index c12ba3aa08..d97c3d3980 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -7,7 +7,6 @@ window.habitrpg = angular.module('habitrpg', // @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705 // temporary hack until they have a better solution - .value('$anchorScroll', angular.noop) .constant("API_URL", "") .constant("STORAGE_USER_ID", 'habitrpg-user') @@ -185,6 +184,10 @@ window.habitrpg = angular.module('habitrpg', url: '/timetravelers', templateUrl: "partials/options.inventory.timetravelers.html" }) + .state('options.inventory.seasonalshop', { + url: '/seasonalshop', + templateUrl: "partials/options.inventory.seasonalshop.html" + }) // Options > Settings .state('options.settings', { diff --git a/public/js/controllers/footerCtrl.js b/public/js/controllers/footerCtrl.js index 9017602aec..4a42b10952 100644 --- a/public/js/controllers/footerCtrl.js +++ b/public/js/controllers/footerCtrl.js @@ -42,6 +42,7 @@ //$.getScript("//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4"); //FIXME why isn't this working when here? instead it's now in var addthisServices = 'facebook,twitter,googleplus,tumblr,'+window.env.BASE_URL.replace('https://','').replace('http://',''); window.addthis_config = { + ui_click: true, services_custom:{ name: "Download", url: window.env.BASE_URL+"/export/avatar-"+User.user._id+".png", diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 4e3877ca3f..67ddbc08f5 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -148,21 +148,19 @@ api.loginSocial = function(req, res, next) { network = req.body.network; if (network!=='facebook') return res.json(401, {err:"Only Facebook supported currently."}); - async.waterfall([ - function(cb){ + async.auto({ + profile: function (cb) { passport._strategies[network].userProfile(access_token, cb); }, - function(profile, cb) { - var q = {};q['auth.'+network+'.id'] = profile.id; - User.findOne(q, {_id:1, apiToken:1, auth:1}, function(err, user){ - if (err) return cb(err); - cb(null, {user:user, profile:profile}); - }); - }, - function(data, cb){ - if (data.user) return cb(null, data.user); + user: ['profile', function (cb, results) { + var q = {}; + q['auth.' + network + '.id'] = results.profile.id; + User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb); + }], + register: ['profile', 'user', function (cb, results) { + if (results.user) return cb(null, results.user); // Create new user - var prof = data.profile; + var prof = results.profile; var user = { preferences: { language: req.language // User language detected from browser, not saved @@ -175,15 +173,16 @@ api.loginSocial = function(req, res, next) { user = new User(user); user.save(cb); - if(isProd && prof.emails && prof.emails[0] && prof.emails[0].value){ - utils.txnEmail({name:prof.displayName || prof.username, email:prof.emails[0].value}, 'welcome'); + if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) { + utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome'); } ga.event('register', network).send(); - } - ], function(err, user){ + }] + }, function(err, results){ if (err) return res.json(401, {err: err.toString ? err.toString() : err}); - if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); - return res.json(200, {id: user.id, token:user.apiToken}); + var acct = results.register[0] ? results.register[0] : results.register; + if (acct.auth.blocked) return res.json(401, accountSuspended(acct._id)); + return res.json(200, {id:acct._id, token:acct.apiToken}); }) }; diff --git a/src/controllers/challenges.js b/src/controllers/challenges.js index b5fe4d6982..71e1d82d33 100644 --- a/src/controllers/challenges.js +++ b/src/controllers/challenges.js @@ -147,69 +147,63 @@ api.getMember = function(req, res, next) { // CREATE api.create = function(req, res, next){ var user = res.locals.user; - var group, chal; - // First, make sure they've selected a legit group, and store it for later - var waterfall = [ - function(cb){ - Group.findById(req.body.group).exec(cb); + async.auto({ + get_group: function(cb){ + var q = {_id:req.body.group}; + if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group + Group.findOne(q, cb); }, - function(_group, cb){ - if (!_group) return cb("Group." + req.body.group + " not found"); - group = _group; - cb(null); - } - ]; + save_chal: ['get_group', function(cb, results){ + var group = results.get_group, + prize = +req.body.prize; + if (!group) + return cb({code:404, err:"Group." + req.body.group + " not found"}); + if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id) + return cb({code:401, err: "Only the group leader can create challenges"}); + // If they're adding a prize, do some validation + if (prize < 0) + return cb({code:401, err: 'Challenge prize must be >= 0'}); + if (req.body.group=='habitrpg' && prize < 1) + return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'}); + if (prize > 0) { + var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0); + var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid... + if (prizeCost > user.balance + groupBalance) + return cb("You can't afford this prize. Purchase more gems or lower the prize amount.") - // If they're adding a prize, do some validation - if (+req.body.prize < 0) return res.json(401, {err: 'Challenge prize must be >= 0'}); - if (req.body.group=='habitrpg' && +req.body.prize < 1) return res.json(401, {err: 'Prize must be at least 1 Gem for public challenges.'}); - if (+req.body.prize > 0) { - waterfall.push(function(cb){ - var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0); - var prizeCost = req.body.prize/4; // I really should have stored user.balance as gems rather than dollars... stupid... - if (prizeCost > user.balance + groupBalance) - return cb("You can't afford this prize. Purchase more gems or lower the prize amount.") - - if (groupBalance >= prizeCost) { - // Group pays for all of prize - group.balance -= prizeCost; - } else if (groupBalance > 0) { - // User pays remainder of prize cost after group - var remainder = prizeCost - group.balance; - group.balance = 0; - user.balance -= remainder; - } else { - // User pays for all of prize - user.balance -= prizeCost; - } - cb(null); - }); - } - - waterfall = waterfall.concat([ - function(cb) { // if we're dealing with prize above, arguemnts will be `group, numRows, cb` - else `cb` + if (groupBalance >= prizeCost) { + // Group pays for all of prize + group.balance -= prizeCost; + } else if (groupBalance > 0) { + // User pays remainder of prize cost after group + var remainder = prizeCost - group.balance; + group.balance = 0; + user.balance -= remainder; + } else { + // User pays for all of prize + user.balance -= prizeCost; + } + } req.body.leader = user._id; req.body.official = user.contributor.admin && req.body.official; var chal = new Challenge(req.body); // FIXME sanitize chal.members.push(user._id); - chal.save(cb) - }, - function(_chal, num, cb){ - chal = _chal; - group.challenges.push(chal._id); - group.save(cb); - }, - function(_group, num, cb) { + chal.save(cb); + }], + save_group: ['save_chal', function(cb, results){ + results.get_group.challenges.push(results.save_chal[0]._id); + results.get_group.save(cb); + }], + sync_user: ['save_group', function(cb, results){ // Auto-join creator to challenge (see members.push above) - chal.syncToUser(user, cb); - } - ]); - async.waterfall(waterfall, function(err){ - if (err) return next(err); - res.json(chal); - user = group = chal = null; - }); + results.save_chal[0].syncToUser(user, cb); + }] + }, function(err, results){ + if (err) return err.code? res.json(err.code, err) : next(err); + return res.json(results.save_chal[0]); + user = null; + }) } // UPDATE diff --git a/src/controllers/groups.js b/src/controllers/groups.js index caa36809cc..93a6d2d0ba 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -187,7 +187,7 @@ api.update = function(req, res, next) { if(group.leader !== user._id) return res.json(401, {err: "Only the group leader can update the group!"}); - 'name description logo logo leaderMessage leader'.split(' ').forEach(function(attr){ + 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ group[attr] = req.body[attr]; }); diff --git a/src/controllers/payments/iap.js b/src/controllers/payments/iap.js new file mode 100644 index 0000000000..29b03fedfb --- /dev/null +++ b/src/controllers/payments/iap.js @@ -0,0 +1,129 @@ +var iap = require('in-app-purchase'); +var async = require('async'); +var payments = require('./index'); +var nconf = require('nconf'); + +var inAppPurchase = require('in-app-purchase'); +inAppPurchase.config({ + // this is the path to the directory containing iap-sanbox/iap-live files + googlePublicKeyPath: nconf.get("IAP_GOOGLE_KEYDIR") +}); + +// Validation ERROR Codes +var INVALID_PAYLOAD = 6778001; +var CONNECTION_FAILED = 6778002; +var PURCHASE_EXPIRED = 6778003; + +exports.androidVerify = function(req, res, next) { + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + console.error('IAP Setup ERROR'); + console.error(error); + + res.json(resObj); + + return; + } + + /* + google receipt must be provided as an object + { + "data": "{stringified data object}", + "signature": "signature from google" + } + */ + var testObj = { + data: iapBody.transaction.receipt, + signature: iapBody.transaction.signature + }; + + // iap is ready + iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + res.json(resObj); + console.error(err); + return; + } + + if (iap.isValidated(googleRes)) { + var resObj = { + ok: true, + data: googleRes + }; + + payments.buyGems({user:user, paymentMethod:'IAP GooglePlay'}); + + // yay good! + res.json(resObj); + } + }); + }); +}; + +exports.iosVerify = function(req, res, next) { + console.info(req.body); + + var iapBody = req.body; + var user = res.locals.user; + + iap.setup(function (error) { + if (error) { + var resObj = { + ok: false, + data: 'IAP Error' + }; + + console.error('IAP Setup ERROR'); + console.error(error); + + res.json(resObj); + + return; + } + + // iap is ready + iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { + if (err) { + var resObj = { + ok: false, + data: { + code: INVALID_PAYLOAD, + message: err.toString() + } + }; + + res.json(resObj); + console.error(err); + return; + } + + if (iap.isValidated(appleRes)) { + var resObj = { + ok: true, + data: appleRes + }; + + payments.buyGems({user:user, paymentMethod:'IAP AppleStore'}); + + // yay good! + res.json(resObj); + } + }); + }); +}; \ No newline at end of file diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js index 176098b4c5..b0c59f643c 100644 --- a/src/controllers/payments/index.js +++ b/src/controllers/payments/index.js @@ -7,9 +7,9 @@ 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'); +var iap = require('./iap'); function revealMysteryItems(user) { _.each(shared.content.gear.flat, function(item) { @@ -123,4 +123,7 @@ exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; exports.paypalSubscribeCancel = paypal.cancelSubscription; exports.paypalCheckout = paypal.createPayment; exports.paypalCheckoutSuccess = paypal.executePayment; -exports.paypalIPN = paypal.ipn; \ No newline at end of file +exports.paypalIPN = paypal.ipn; + +exports.iapAndroidVerify = iap.androidVerify; +exports.iapIosVerify = iap.iosVerify; diff --git a/src/controllers/user.js b/src/controllers/user.js index bceb3eb677..7bf164cd73 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -257,37 +257,45 @@ api.update = function(req, res, next) { }; api.cron = function(req, res, next) { - var user = res.locals.user, - progress = user.fns.cron(), - ranCron = user.isModified(), - quest = shared.content.quests[user.party.quest.key]; + try{ + var user = res.locals.user, + progress = user.fns.cron(), + ranCron = user.isModified(), + quest = shared.content.quests[user.party.quest.key]; + if (ranCron) res.locals.wasModified = true; + if (!ranCron) return next(null,user); + Group.tavernBoss(user,progress); + if (!quest) return user.save(next); - if (ranCron) res.locals.wasModified = true; - if (!ranCron) return next(null,user); - Group.tavernBoss(user,progress); - if (!quest) return user.save(next); - - // If user is on a quest, roll for boss & player, or handle collections - // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? - async.waterfall([ - function(cb){ - user.save(cb); // make sure to save the cron effects - }, - function(saved, count, cb){ - var type = quest.boss ? 'boss' : 'collect'; - Group[type+'Quest'](user,progress,cb); - }, - function(){ - var cb = arguments[arguments.length-1]; - // User has been updated in boss-grapple, reload - User.findById(user._id, cb); - } - ], function(err, saved) { - res.locals.user = saved; - next(err,saved); - user = progress = quest = null; - }); + // If user is on a quest, roll for boss & player, or handle collections + // FIXME this saves user, runs db updates, loads user. Is there a better way to handle this? + async.waterfall([ + function(cb){ + user.save(cb); // make sure to save the cron effects + }, + function(saved, count, cb){ + var type = quest.boss ? 'boss' : 'collect'; + Group[type+'Quest'](user,progress,cb); + }, + function(){ + var cb = arguments[arguments.length-1]; + // User has been updated in boss-grapple, reload + User.findById(user._id, cb); + } + ], function(err, saved) { + if(err) logging.loggly({error: "Cron caught", stack: err.stack || err}); + res.locals.user = saved; + next(err,saved); + user = progress = quest = null; + }); + }catch(e){ + logging.loggly({ + error: "Cron uncaught", + stack: e.stack || e + }); + throw e; + } }; diff --git a/src/logging.js b/src/logging.js index 5e1449db3d..5c0d973812 100644 --- a/src/logging.js +++ b/src/logging.js @@ -3,7 +3,23 @@ var winston = require('winston'); require('winston-mail').Mail; require('winston-newrelic'); -var logger; +var logger, loggly; + +if (nconf.get('LOGGLY:enabled')){ + loggly = require('loggly').createClient({ + token: nconf.get('LOGGLY:token'), + subdomain: nconf.get('LOGGLY:subdomain'), + auth: { + username: nconf.get('LOGGLY:username'), + password: nconf.get('LOGGLY:password') + }, + // + // Optional: Tag to send with EVERY log message + // + tags: [('heroku-'+nconf.get('BASE_URL'))], + json: true + }); +} if (logger == null) { logger = new (winston.Logger)({}); @@ -49,3 +65,8 @@ module.exports.error = function(/* variable args */) { if (logger) logger.error.apply(logger, arguments); }; + +module.exports.loggly = function(/* variable args */){ + if (loggly) + loggly.log.apply(loggly, arguments); +}; diff --git a/src/middleware.js b/src/middleware.js index ba5ed01f07..56e72bda7e 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -75,6 +75,13 @@ module.exports.errorHandler = function(err, req, res, next) { "\n\nbody: " + JSON.stringify(req.body) + (res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : ""); logging.error(stack); + logging.loggly({ + error: "Uncaught error", + stack: (err.stack || err.message || err), + body: req.body, headers: req.header, + auth: (req.headers['x-api-user'] + ' | ' + req.headers['x-api-key']), + originalUrl: req.originalUrl + }); var message = err.message ? err.message : err; message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length); res.json(500,{err:message}); //res.end(err.message); @@ -173,12 +180,9 @@ module.exports.locals = function(req, res, next) { language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); var tavern = require('./models/group').tavern; - res.locals.habitrpg = { - NODE_ENV: nconf.get('NODE_ENV'), - BASE_URL: nconf.get('BASE_URL'), - GA_ID: nconf.get("GA_ID"), + var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY'.split(' ')); + res.locals.habitrpg = _.merge(envVars, { IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), - STRIPE_PUB_KEY: nconf.get('STRIPE_PUB_KEY'), getManifestFiles: getManifestFiles, getBuildUrl: getBuildUrl, avalaibleLanguages: i18n.avalaibleLanguages, @@ -193,11 +197,9 @@ module.exports.locals = function(req, res, next) { siteVersion: siteVersion, Content: shared.content, mods: require('./models/user').mods, - FACEBOOK_KEY: nconf.get('FACEBOOK_KEY'), - tavern: tavern, // for world boss worldDmg: (tavern && tavern.quest && tavern.quest.extra && tavern.quest.extra.worldDmg) || {} - }; + }); // Put query-string party invitations into session to be handled later try{ diff --git a/src/models/group.js b/src/models/group.js index be66b59fa7..2997fd4cfe 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -26,7 +26,10 @@ var GroupSchema = new Schema({ # id: String # }] */ - + leaderOnly: { // restrict group actions to leader (members can't do them) + challenges: {type:Boolean, 'default':false}, + //invites: {type:Boolean, 'default':false} + }, memberCount: {type: Number, 'default': 0}, challengeCount: {type: Number, 'default': 0}, balance: Number, diff --git a/src/routes/payments.js b/src/routes/payments.js index c34d77b24b..adc9a65629 100644 --- a/src/routes/payments.js +++ b/src/routes/payments.js @@ -17,4 +17,7 @@ router.post("/stripe/subscribe/edit", auth.auth, i18n.getUserLanguage, payments. //router.get("/stripe/subscribe", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead router.get("/stripe/subscribe/cancel", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel); +router.post("/iap/android/verify", auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); +router.post("/iap/ios/verify", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.iapIosVerify); + module.exports = router; \ No newline at end of file diff --git a/views/options/inventory/index.jade b/views/options/inventory/index.jade index af191f3de1..b2f0cfb3ec 100644 --- a/views/options/inventory/index.jade +++ b/views/options/inventory/index.jade @@ -17,7 +17,10 @@ script(type='text/ng-template', id='partials/options.inventory.html') =env.t('equipment') li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.timetravelers') }") a(ui-sref='options.inventory.timetravelers') - | Time Travelers + =env.t('timeTravelers') + li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.seasonalshop') }") + a(ui-sref='options.inventory.seasonalshop') + =env.t('seasonalShop') .tab-content .tab-pane.active diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 3d872231a6..da48ed04fc 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -19,6 +19,18 @@ 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.seasonalshop.html') + .container-fluid + .stable.row + .col-md-2 + .seasonalshop_closed + .col-md-10 + .popover.static-popover.fade.right.in + .arrow + h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"", linkEnd: ""}) + .popover-content + p!=env.t('seasonalShopClosedText', {linkStart: "", linkEnd: ""}) + script(type='text/ng-template', id='partials/options.inventory.timetravelers.html') .container-fluid .stable.row(ng-if='user.purchased.plan.consecutive.trinkets <= 0') diff --git a/views/options/profile.jade b/views/options/profile.jade index 6e212061dd..cf8fef0e25 100644 --- a/views/options/profile.jade +++ b/views/options/profile.jade @@ -15,8 +15,9 @@ mixin customizeProfile(mobile) if ~['limited','seasonal'].indexOf(status) .label.label-info.pull-right.hint(popover=limited, popover-title=env.t(status+'Edition'), popover-placement='right', popover-trigger='mouseenter')=env.t(status+'Edition') menu(label=env.t(title)) - +gemCost(2) - button.btn.btn-xs(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "||")}', ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' ' + span(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' ' each color in colors button.customize-option(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-class='{locked: !user.purchased.#{path}["#{color}"]}', ng-if='#{status!="disabled"} || user.purchased.#{path}["#{color}"]', ng-click='unlock("#{path}.#{color}")') @@ -38,8 +39,9 @@ mixin customizeProfile(mobile) menu(label=env.t('specialShirts')) - var specialShirts = ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie'] - +gemCost(2) - button.btn.btn-xs(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}', ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' ' + span(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' ' each shirt in specialShirts button.customize-option(type='button', class='{{user.preferences.size}}_shirt_'+shirt, ng-class='{locked: !user.purchased.shirt.'+shirt+'}', ng-click='unlock("shirt.'+shirt+'")') @@ -80,8 +82,9 @@ mixin customizeProfile(mobile) // Purchasable hairstyles menu(label=env.t('hairSet1')) - var colors = [2,4,5,6,7,8] - +gemCost(2) - button.btn.btn-xs(ng-hide='#{showPath("user.purchased.hair", colors, "&&")}', ng-click='#{unlockPath("hair.base",colors)}')!= env.t('unlockSet',{cost:5}) + ' ' + span(ng-hide='#{showPath("user.purchased.hair.base", colors, "&&")}') + +gemCost(2) + button.btn.btn-xs(ng-click='#{unlockPath("hair.base",colors)}')!= env.t('unlockSet',{cost:5}) + ' ' each num in colors button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base["#{num}"]}', ng-click='unlock("hair.base.#{num}")') @@ -93,9 +96,10 @@ mixin customizeProfile(mobile) button(class='hair_flower_#{num} customize-option', type='button', ng-click='set({"preferences.hair.flower":#{num}})') li.customize-menu - h5=env.t('bodyFacialHair') - +gemCost(2) - button.btn.btn-xs(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]', ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' ' + menu(label=env.t('bodyFacialHair')) + span(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]') + +gemCost(2) + button.btn.btn-xs(ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' ' // Beard menu(label=env.t('beard')) @@ -249,9 +253,9 @@ mixin backgrounds(mobile) - var k = bgsKeys[i], bgs = env.Content.backgrounds[k]; li.customize-menu menu(label=env.t(k)) - +gemCost(7) - //-button.btn.btn-xs(ng-hide="ownsSet('background',Content.backgrounds['#{k}'])",ng-click="unlock(setKeys('background',Content.backgrounds['#{k}']))")!= env.t('unlockSet',{cost:15}) + ' ' - button.btn.btn-xs(ng-hide="ownsSet('background',#{JSON.stringify(bgs)})",ng-click="unlock(setKeys('background',#{JSON.stringify(bgs)}))")!= env.t('unlockSet',{cost:15}) + ' ' + span(ng-hide="ownsSet('background',#{JSON.stringify(bgs)})") + +gemCost(7) + button.btn.btn-xs(ng-click="unlock(setKeys('background',#{JSON.stringify(bgs)}))")!= env.t('unlockSet',{cost:15}) + ' ' each bg,k in bgs button.customize-option(type='button', class='background_#{k}', ng-class="user.purchased.background.#{k} ? 'background-unlocked' : 'background-locked'", ng-click='unlock("background.#{k}")', popover-title=bg.text(), popover=bg.notes(),popover-trigger='mouseenter') i.glyphicon.glyphicon-lock(ng-if="!user.purchased.background.#{k}") diff --git a/views/options/social/chat-message.jade b/views/options/social/chat-message.jade index afe4d2e1b3..e93f304b7d 100644 --- a/views/options/social/chat-message.jade +++ b/views/options/social/chat-message.jade @@ -31,7 +31,7 @@ mixin chatMessages(inbox) |     a(ng-click="flagChatMessage(group._id, message)", ng-if=':: user.contributor.admin || (!message.sent && user.flags.communityGuidelinesAccepted && message.uuid != user.id && message.uuid != "system")') span.glyphicon.glyphicon-flag(tooltip="{{message.flags[user._id] ? env.t('abuseAlreadyReported') : env.t('abuseFlag')}}" ng-class='message.flags[user._id] ? "text-danger" : ""') - span.float-label + span.float-label(ng-class='::contribText(message.contributor, message.backer).length > 30 ? "long-title" : ""') a.label.label-default.chat-message(ng-if=':: message.user', ng-class='::userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') |   diff --git a/views/options/social/create-group.jade b/views/options/social/create-group.jade index b2211862c8..6c0c4c20e8 100644 --- a/views/options/social/create-group.jade +++ b/views/options/social/create-group.jade @@ -19,5 +19,10 @@ form.col-md-12.form-horizontal(ng-submit='create(newGroup)') span.gem-cost= '4 ' + env.t('gems') p small=env.t('gemCost') + .form-group + .checkbox + label + input(type='checkbox', ng-model='newGroup.leaderOnly.challenges') + | Only group leader can create challenges .form-group(ng-show='type=="party"') input.btn.btn-default.form-control(type='submit', value=env.t('create')) diff --git a/views/options/social/group.jade b/views/options/social/group.jade index 1b1d7a6f84..a3c5e257d8 100644 --- a/views/options/social/group.jade +++ b/views/options/social/group.jade @@ -33,6 +33,11 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter .form-group label=env.t('logoUrl') input.form-control(type='url', placeholder=env.t('logoUrl'), ng-model='group.logo') + .form-group + .checkbox + label + input(type='checkbox', ng-model='group.leaderOnly.challenges') + | Only group leader can create challenges h4=env.t('assignLeader') select#group-leader-selection(ng-model='group._newLeader', ng-options='member.profile.name for member in group.members') diff --git a/views/options/social/index.jade b/views/options/social/index.jade index 89a1e98e7e..24b86ab7e5 100644 --- a/views/options/social/index.jade +++ b/views/options/social/index.jade @@ -105,13 +105,13 @@ script(type='text/ng-template', id='partials/options.social.html') a(ui-sref='options.social.party') =env.t('party') li(ng-class="{ active: $state.includes('options.social.guilds') }") - a(ui-sref='options.social.guilds') + a(ui-sref='options.social.guilds.public') =env.t('guilds') li(ng-class="{ active: $state.includes('options.social.challenges') }") a(ui-sref='options.social.challenges') =env.t('challenges') li(ng-class="{ active: $state.includes('options.social.hall') }") - a(ui-sref='options.social.hall') + a(ui-sref='options.social.hall.heroes') =env.t('hall') .tab-content diff --git a/views/options/social/tavern.jade b/views/options/social/tavern.jade index 9eea1159f3..0739126588 100644 --- a/views/options/social/tavern.jade +++ b/views/options/social/tavern.jade @@ -21,7 +21,7 @@ span(ng-hide='user.preferences.sleep')=env.t('innCheckIn') =env.t('danielText2') .alert.alert-info(ng-show='user.preferences.sleep') - =env.t('innText') + =env.t('innText',{name:"{{user.profile.name}}"}) // Resources .panel.panel-default diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index 486cdd95f3..467fbeeab7 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -34,11 +34,11 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.social.party')=env.t('party') li - a(ui-sref='options.social.guilds')=env.t('guilds') + a(ui-sref='options.social.guilds.public')=env.t('guilds') li a(ui-sref='options.social.challenges')=env.t('challenges') li - a(ui-sref='options.social.hall')=env.t('hall') + a(ui-sref='options.social.hall.heroes')=env.t('hall') ul.toolbar-submenu li a(ui-sref='options.inventory.drops')=env.t('market') @@ -49,7 +49,9 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.inventory.equipment')=env.t('equipment') li - a(ui-sref='options.inventory.timetravelers') Time Travelers + a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers') + li + a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop') ul.toolbar-submenu li a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') @@ -110,11 +112,11 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.social.party')=env.t('party') li - a(ui-sref='options.social.guilds')=env.t('guilds') + a(ui-sref='options.social.guilds.public')=env.t('guilds') li a(ui-sref='options.social.challenges')=env.t('challenges') li - a(ui-sref='options.social.hall')=env.t('hall') + a(ui-sref='options.social.hall.heroes')=env.t('hall') li.toolbar-button-dropdown a(ui-sref='options.inventory.drops') span=env.t('inventory') @@ -131,7 +133,9 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.inventory.equipment')=env.t('equipment') li - a(ui-sref='options.inventory.timetravelers') Time Travelers + a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers') + li + a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop') 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/quests.jade b/views/shared/modals/quests.jade index ccd4d39850..6ccf0dd06f 100644 --- a/views/shared/modals/quests.jade +++ b/views/shared/modals/quests.jade @@ -5,6 +5,7 @@ script(type='text/ng-template', id='modals/questCompleted.html') h4 "{{::Content.quests[user.party.quest.completed].text()}}" =env.t('completed') .modal-body + div(ng-class='::Content.quests[user.party.quest.completed].completion() ? "pull-right-sm" : ""', class='col-centered quest_{{user.party.quest.completed}}') p(ng-bind-html='::Content.quests[user.party.quest.completed].completion()') quest-rewards(key='{{user.party.quest.completed}}', header=env.t('youReceived')) .modal-footer diff --git a/views/shared/new-stuff.jade b/views/shared/new-stuff.jade index b059de69df..8162251f90 100644 --- a/views/shared/new-stuff.jade +++ b/views/shared/new-stuff.jade @@ -1,19 +1,40 @@ -h5 PENGUIN PET QUEST AND WINTER PLOT-LINE +h5 ANDROID APP UPDATE, SEASONAL SHOP, AND WINTER PLOT-LINE CONTINUES tr td - h5 Penguin Pet Quest - .quest_penguin.pull-right - p Habiticans wanted to go ice-skating, but instead, a giant penguin is freezing everything in sight! All we wanted was to go ice-skating... Can you get this penguin to chill out? If so, you'll be rewarded with some penguins of your own! - p.small.muted by Melynnrose, Breadstrings, Rattify, Painter de Cluster, Daniel the Bard, and Leephon + h5 Android App Update: December Art and Buying Gems! + p The December backgrounds and penguin pet quest are now visible in the Android mobile app! Also, we’ve made it possible to buy gems directly from the app. Now you don’t have to switch to the website to stock up! + p You can get the Android app here! We will announce when the iOS app is available as well. + p.small.muted by negue tr td - h5 Winter Plot-Line - p Lemoness enters the Tavern with worrying news from the far north of Habitica. "Nobody's heard from the Stoïkalm Steppes for over a week," she says. "It's hard to imagine anything troubling the citizens there, since it's such a placid part of the continent... But just in case, maybe I should pay a visit." Sounds like a good plan to us! + h5 Seasonal Shop Tab + .seasonalshop_closed.pull-right + p Looks like a new tab has appeared under Inventory - the Seasonal Shop! It's still closed, but I've heard a rumor that it will open soon... + p.small.muted by SabreCat and Lemoness + tr + td + h5 Winter Plot-Line Continues + p Lemoness bursts into the Tavern, shaking icicles off her hat. "The Stoïkalm Steppes are completely abandoned!" she says, gulping the cup of tea that Daniel the Barkeep offers her. "No people milling about, no mounts and pets playing in the snow - and when I tried to fly closer, my dragon spooked and refused to land!" + p A cloaked figure in the corner steps into the fire light - SabreCat, a powerful adventurer from the north. "The Stoïkalm Steppes are the last home of many animals that have long since gone extinct elsewhere," he says. "The stoic Stoïkalmers would never flee their lands unless something was threatening their pets and mounts!" + p He turns to Lemoness. "I can speak the language of the northern beasts. I'll try to contact the roaming sabertooth prides to see if they know what happened." As he lopes off into the distance, a cold wind begins to blow. + p.muted Missed the first part of the Winter Plot-Line? Read it here. hr a(href='/static/old-news', target='_blank') Read older news mixin oldNews + h5 12/9/2014 - Penguin Pet Quest and Winter Plot-Line + tr + td + h5 Penguin Pet Quest + .quest_penguin.pull-right + p Habiticans wanted to go ice-skating, but instead, a giant penguin is freezing everything in sight! All we wanted was to go ice-skating... Can you get this penguin to chill out? If so, you'll be rewarded with some penguins of your own! + p.small.muted by Melynnrose, Breadstrings, Rattify, Painter de Cluster, Daniel the Bard, and Leephon + tr + td + h5 Winter Plot-Line + p Lemoness enters the Tavern with worrying news from the far north of Habitica. "Nobody's heard from the Stoïkalm Steppes for over a week," she says. "It's hard to imagine anything troubling the citizens there, since it's such a placid part of the continent... But just in case, maybe I should pay a visit." Sounds like a good plan to us! + h5 12/3/2014 - Gifting Subscriptions And Gems, New Subscription Benefits, Mysterious Time Travelers, Steampunk Item Sets, And Block Subscriptions! table.table.table-striped tr