From 50b1cba0a6a87ca0b5fcbd99dbe474e58c0c770d Mon Sep 17 00:00:00 2001 From: benmanley Date: Thu, 27 Nov 2014 10:32:57 +0000 Subject: [PATCH] refactor(tasks) improve UI consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Round corners for UI elements / “8-bit” outlines for RPG elements. * Tag bar – Make it clearer that “Tags” is a heading, not an option * Task filters – restyle in line with tag bar + nav menu --- .buildpacks | 2 + bower.json | 23 +- config.json.example | 22 +- migrations/20141126_turkey_mounts.js | 11 + migrations/mysteryitems.js | 2 +- package.json | 15 +- public/css/avatar.styl | 22 +- public/css/filters.styl | 11 +- public/css/global-colors.styl | 4 +- public/css/global-modules.styl | 64 +++- public/css/tasks.styl | 105 +++--- public/js/app.js | 7 +- public/js/controllers/authCtrl.js | 42 +-- public/js/controllers/footerCtrl.js | 14 +- public/js/controllers/groupsCtrl.js | 53 ++- public/js/controllers/rootCtrl.js | 9 +- public/js/controllers/settingsCtrl.js | 21 +- public/js/services/memberServices.js | 4 +- public/js/static.js | 18 +- public/manifest.json | 8 +- src/controllers/auth.js | 151 +++----- src/controllers/dataexport.js | 53 +++ src/controllers/groups.js | 18 +- src/controllers/hall.js | 10 +- src/controllers/members.js | 50 +++ src/controllers/payments.js | 324 ------------------ src/controllers/payments/index.js | 77 +++++ src/controllers/payments/paypal.js | 166 +++++++++ .../payments/paypalBillingSetup.js | 68 ++++ src/controllers/payments/stripe.js | 89 +++++ src/controllers/user.js | 61 +++- src/middleware.js | 9 +- src/models/group.js | 2 +- src/models/user.js | 21 +- src/routes/apiv2.coffee | 66 +++- src/routes/auth.js | 4 +- src/routes/dataexport.js | 5 +- src/server.js | 33 +- src/utils.js | 58 +++- test/api.mocha.coffee | 2 +- views/avatar-static.jade | 28 ++ views/index.jade | 3 + views/options/inventory/inventory.jade | 4 +- views/options/inventory/stable.jade | 4 +- views/options/profile.jade | 27 +- views/options/settings.jade | 68 ++-- views/options/social/chat-box.jade | 29 +- views/options/social/chat-message.jade | 51 ++- views/options/social/group.jade | 11 +- views/options/social/index.jade | 23 +- views/options/social/tavern.jade | 11 +- views/shared/header/avatar.jade | 2 + views/shared/header/menu.jade | 27 ++ views/shared/modals/index.jade | 1 + views/shared/modals/invite-friends.jade | 48 +++ views/shared/modals/members.jade | 52 ++- views/shared/new-stuff.jade | 107 +++++- views/shared/tasks/lists.jade | 36 +- views/shared/tasks/task.jade | 26 +- views/static/community-guidelines.jade | 239 +++---------- views/static/front.jade | 78 +++-- views/static/login-modal.jade | 13 +- 62 files changed, 1607 insertions(+), 1005 deletions(-) create mode 100644 .buildpacks create mode 100644 migrations/20141126_turkey_mounts.js create mode 100644 src/controllers/members.js delete mode 100644 src/controllers/payments.js create mode 100644 src/controllers/payments/index.js create mode 100644 src/controllers/payments/paypal.js create mode 100644 src/controllers/payments/paypalBillingSetup.js create mode 100644 src/controllers/payments/stripe.js create mode 100644 views/avatar-static.jade create mode 100644 views/shared/modals/invite-friends.jade diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000000..b57b4bd3b0 --- /dev/null +++ b/.buildpacks @@ -0,0 +1,2 @@ +https://github.com/heroku/heroku-buildpack-nodejs.git +https://github.com/stomita/heroku-buildpack-phantomjs.git diff --git a/bower.json b/bower.json index b00287d040..5819739e29 100644 --- a/bower.json +++ b/bower.json @@ -16,16 +16,15 @@ "dependencies": { "jquery": "~2.1.0", "jquery.cookie": "~1.4.0", - "angular": "1.3.0-beta.11", + "angular": "1.3.3", "angular-ui": "~0.4.0", - "angular-sanitize": "1.3.0-beta.11", - "angular-resource": "1.3.0-beta.11", + "angular-sanitize": "1.3.3", + "angular-resource": "1.3.3", "angular-ui-utils": "~0.1.0", "angular-ui-select2": "git://github.com/angular-ui/ui-select2.git", - "angular-bootstrap": "~0.10.0", + "angular-bootstrap": "~0.12.0", "angular-ui-router": "git://github.com/HabitRPG/ui-router.git#habitrpg", - "angular-loading-bar": "~0.3.0", - "angular-bindonce": "~0.2.1", + "angular-loading-bar": "~0.6.0", "bootstrap": "~3.1.0", "bootstrap-growl": "git://github.com/ifightcrime/bootstrap-growl.git#master", "bootstrap-tour": "~0.8.1", @@ -34,18 +33,22 @@ "github-buttons": "git://github.com/mdo/github-buttons.git", "marked": "~0.2.9", "Angular-At-Directive": "git://github.com/snicker/Angular-At-Directive#master", - "js-emoji": "git://github.com/snicker/js-emoji#master", + "js-emoji": "https://github.com/iamcal/js-emoji.git", "sticky": "*", "swagger-ui": "git://github.com/wordnik/swagger-ui.git", "ngInfiniteScroll": "1.0.0", "jquery-colorbox": "~1.4.36", "pnotify": "~1.3.1", - "jquery-ui": "~1.10.3" + "jquery-ui": "~1.10.3", + "hello": "~1.3.1", + "css-social-buttons": "https://github.com/samcollins/css-social-buttons.git", + "angular-filter": "~0.5.1" }, "devDependencies": { - "angular-mocks": "1.3.0-beta.11" + "angular-mocks": "1.3.3" }, "resolutions": { - "angular": "1.3.0-beta.11" + "angular": "1.3.3", + "jquery": ">=1.9.0" } } diff --git a/config.json.example b/config.json.example index 821bb91fc3..a9e7d5f4ac 100644 --- a/config.json.example +++ b/config.json.example @@ -20,10 +20,20 @@ "NEW_RELIC_APPLICATION_ID":"NEW_RELIC_APPLICATION_ID", "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", - "PAYPAL_USERNAME": "PAYPAL_USERNAME", - "PAYPAL_PASSWORD": "PAYPAL_PASSWORD", - "PAYPAL_SIGNATURE": "PAYPAL_SIGNATURE", - "EMAIL_SERVER_URL": "http://example.com", - "EMAIL_SERVER_AUTH_USER": "user", - "EMAIL_SERVER_AUTH_PASSWORD": "password" + "EMAIL_SERVER": { + "url": "http://example.com", + "authUser": "user", + "authPassword": "password" + }, + "S3":{ + "bucket":"bucket", + "accessKeyId":"accessKeyId", + "secretAccessKey":"secretAccessKey" + }, + "PAYPAL":{ + "billing_plan_id":"billing_plan_id", + "mode":"sandbox", + "client_id":"client_id", + "client_secret":"client_secret" + } } diff --git a/migrations/20141126_turkey_mounts.js b/migrations/20141126_turkey_mounts.js new file mode 100644 index 0000000000..1270390c06 --- /dev/null +++ b/migrations/20141126_turkey_mounts.js @@ -0,0 +1,11 @@ +db.users.update( + {'items.pets.Turkey-Base':{$ne:null}}, + {$set:{'items.mounts.Turkey-Base':true}}, + {multi:1} +) + +db.users.update( + {'items.pets.Turkey-Base':null}, + {$set:{'items.pets.Turkey-Base':5}}, + {multi:1} +) \ No newline at end of file diff --git a/migrations/mysteryitems.js b/migrations/mysteryitems.js index 1531f8fb0f..b222047bda 100644 --- a/migrations/mysteryitems.js +++ b/migrations/mysteryitems.js @@ -2,7 +2,7 @@ var _id = ''; var update = { $push: { 'purchased.plan.mysteryItems':{ - $each:['back_mystery_201410','armor_mystery_201410'] + $each:['head_mystery_201411','weapon_mystery_201411'] } } }; diff --git a/package.json b/package.json index 3a8e57c1ce..fc3edb043b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "./src/server.js", "dependencies": { "async": "~0.9.0", + "aws-sdk": "^2.0.25", "bower": "~1.3.12", "coffee-script": "1.6.x", "connect-ratelimit": "0.0.7", @@ -25,24 +26,26 @@ "grunt-nodemon": "~0.3.0", "habitrpg-shared": "git://github.com/HabitRPG/habitrpg-shared#develop", "icalendar": "git://github.com/lefnire/node-icalendar#master", + "jade": "~1.7.0", "js2xmlparser": "~0.1.2", "lodash": "~2.4.1", - "jade": "~1.7.0", "method-override": "~2.2.0", - "mongoose": "~3.8.17", "moment": "~2.8.3", + "mongoose": "~3.8.17", "mongoose-id-autoinc": "~2013.7.14-4", "nconf": "~0.6.9", "newrelic": "~1.11.2", "nib": "~1.0.1", "nodemailer": "~0.5.2", + "pageres": "^1.0.1", "passport": "~0.2.1", - "passport-facebook": "~1.0.2", - "paypal-express-checkout": "git://github.com/HabitRPG/node-paypal-express-checkout#habitrpg", - "paypal-ipn": "~1.0.1", - "paypal-recurring": "git://github.com/jaybryant/paypal-recurring#656b496f43440893c984700191666a5c5c535dca", + "passport-facebook": "Fonger/passport-facebook#a8f98adcddad99caa9a918bc7b76462c92c5c9fd", + "paypal-ipn": "2.1.0", + "paypal-rest-sdk": "^1.2.1", "pretty-data": "git://github.com/vkiryukhin/pretty-data#master", + "qs": "^2.3.2", "request": "~2.44.0", + "s3-upload-stream": "^1.0.6", "stripe": "*", "swagger-node-express": "git://github.com/lefnire/swagger-node-express#habitrpg", "universal-analytics": "~0.3.2", diff --git a/public/css/avatar.styl b/public/css/avatar.styl index 10432e1c77..f5132b070f 100644 --- a/public/css/avatar.styl +++ b/public/css/avatar.styl @@ -9,9 +9,13 @@ Seeing as the sprites might change drastically in the future re: pets and whatnot, this is just temporary. ---------------------------------------------------- */ -.profile-modal-header - width:560px - margin: 0px auto +// see http://stackoverflow.com/questions/24166568/set-bootstrap-modal-body-height-by-percentage +.profile-modal + .modal-dialog,.modal-content + height: 96% + .modal-body + max-height: calc(100% - 150px) //100% = dialog height, 150px = header + footer + overflow-y: scroll .herobox // Base styles @@ -73,7 +77,17 @@ future re: pets and whatnot, this is just temporary. &.isUser background: $color-herobox // Set a different background color for the current user &:hover, &:focus - background: lighten($color-herobox, 16.18%) + background: lighten($color-herobox, 16.18%) + + .addthis_pill_style + width: 50px !important + .addthis_native_toolbox + position: absolute + top:2px + right:2px + opacity:0 + &:hover .addthis_native_toolbox + opacity:1 //Need to find a way to indicate who is leader without using background-color as it won't work when a background image is applied //.herobox.isLeader.noBackgroundImage diff --git a/public/css/filters.styl b/public/css/filters.styl index 9fbc791b80..ca0245c97a 100644 --- a/public/css/filters.styl +++ b/public/css/filters.styl @@ -8,7 +8,7 @@ list-style: none margin-right: 1em .filters-controls - @extend $hrpg-button-bar + @extend $hrpg-button-bar-with-heading hrpg-button-bar-mixin($color-options-menu) li @extend $hrpg-button-master @@ -18,11 +18,14 @@ .filters-edit margin-bottom: 0.382em .filters-tags - @extend $hrpg-tag-master - hrpg-tag-color-mixin($color-filter-tag) + @extend $hrpg-button + hrpg-button-color-mixin($color-filter-tag) margin-bottom: 0.618em + margin-right: 0.618em &.challenge - hrpg-tag-color-mixin($color-filter-tag-challenge) + hrpg-button-color-mixin($color-filter-tag-challenge) + span + margin-right: 0.382em form display: none markdown diff --git a/public/css/global-colors.styl b/public/css/global-colors.styl index 488f460565..60dfa28db7 100644 --- a/public/css/global-colors.styl +++ b/public/css/global-colors.styl @@ -24,8 +24,8 @@ $color-button-style-one = $best // Task background $color-tasks = lighten($color-herobox, 65%) // Task filter colors -$color-filter-tag = lighten($good,90%) -$color-filter-tag-challenge = lighten($color-purple,90%) +$color-filter-tag = lighten($good,60%) +$color-filter-tag-challenge = lighten($color-purple,70%) // Contributor colors $color-contributor-one = #f57a9d $color-contributor-two = #b93030 diff --git a/public/css/global-modules.styl b/public/css/global-modules.styl index e341d2a92f..7bed317b44 100644 --- a/public/css/global-modules.styl +++ b/public/css/global-modules.styl @@ -17,13 +17,26 @@ hrpg-button-color-mixin($hrpg-button-color) > a, > button background-color: $hrpg-button-color !important &:active - background-color: darken($hrpg-button-color, 10%) !important + background-color: darken($hrpg-button-color, 61.18%) !important @media screen and (min-width:768px) &:hover background-color: darken($hrpg-button-color, 2.36%) !important > a, > button, > input color: darken($hrpg-button-color, 70%) !important border-color: darken($hrpg-button-color, 16.18%) !important + > input + &:hover + border-color: darken($hrpg-button-color, 32.8%) !important + &:focus + border-color: darken($hrpg-button-color, 61.8%) !important; + outline: none; + > input + button + &:focus + border-color: darken($hrpg-button-color, 32.8%) !important; + background-color: darken($hrpg-button-color, 6.18%) !important; + outline: none; + &:active + background-color: darken($hrpg-button-color, 16.18%) !important; > a:nth-of-type(2) border-left: 1px solid darken($hrpg-button-color, 3.82%) !important > div @@ -48,7 +61,14 @@ hrpg-button-color-mixin($hrpg-button-color) &.active a, button background-color: darken($hrpg-button-color, 3.82%) !important - border-color: darken($hrpg-button-color, 38.2%) !important + border-color: darken($hrpg-button-color, 50%) !important + &.active.filters-tags + a, button + background-color: darken($hrpg-button-color, 32.8%) !important + border-color: $hrpg-button-color !important + color: #fff !important; + span + color: #fff !important; $hrpg-button-master list-style: none > a, > button, > input @@ -61,11 +81,9 @@ $hrpg-button-master .glyphicon position: relative top: 0.132em + > a, > button &:active background-color: #aaa !important - @media screen and (min-width:768px) - &:hover - background-color: #eee !important $hrpg-button @extend $hrpg-button-master > a, > button, > input @@ -95,8 +113,11 @@ $hrpg-button-with-input border: 1px solid #ccc border-radius: 0.382em 0em 0em 0.382em padding-left: 0.618em - &:hover - background-color: #f5f5f5 !important + background-color: #fff !important + -webkit-appearance: none + -moz-appearance: none + appearance: none + box-shadow: none //remove red glow in Firefox a, button border-width: 1px border-color: #ccc @@ -105,31 +126,44 @@ $hrpg-button-with-input border-bottom-style: solid border-left: none border-radius: 0em 0.382em 0.382em 0em + outline: none //Button bar hrpg-button-bar-mixin($hrpg-button-bar-color) border-color: darken($hrpg-button-bar-color, 16.18%) li - border-right-color: darken($hrpg-button-bar-color,3.82%) + border-right-color: darken($hrpg-button-bar-color,6.18%) + &.active + a + box-shadow: 0 0 0 1px darken($hrpg-button-bar-color,23.8%) !important li:first-of-type - background-color: darken($hrpg-button-bar-color,6.18%) - color: darken($hrpg-button-bar-color,70%) - border-right-color: darken($hrpg-button-bar-color,16.18%) + color: darken($hrpg-button-bar-color,50%); + //border-right-color: darken($hrpg-button-bar-color,16.18%) + $hrpg-button-bar + list-style: none + display: inline-block border: 1px solid darken(#fff,16.18%) border-radius: 0.382em margin-bottom: 0.618em + @extend $clearfix li border-right: 1px solid darken(#fff,3.82%) float:left - li:first-of-type - color: darken(#fff,61.8%) - padding: 0.25em 0.618em + li:nth-of-type(2) border-radius: 0.382em 0em 0em 0.382em - border-right: 1px solid darken(#fff,6.18%) + li:first-of-type + border-radius: 0.382em 0em 0em 0.382em + a + border-radius: 0.382em 0em 0em 0.382em li:last-of-type border-right: none a border-radius: 0em 0.382em 0.382em 0em + +$hrpg-button-bar-with-heading + @extend $hrpg-button-bar + li:first-of-type + padding: 0.25em 0.618em // Labels hrpg-label-color-mixin($hrpg-label-color) background-color: $hrpg-label-color !important diff --git a/public/css/tasks.styl b/public/css/tasks.styl index 602d4ff42b..fd1a56e15c 100644 --- a/public/css/tasks.styl +++ b/public/css/tasks.styl @@ -67,53 +67,20 @@ for $stage in $stages max-height: 18.6em overflow-y: scroll -// add new task form +// "Add new task" form // ----------------- -.addtask-form - margin-bottom: 0.75em - outline: 1px solid rgba(0,0,0,0.15) - outline-offset: -1px - background-color: white - // box-shadow: 0 0 3px rgba(0,0,0,0.15) - position: relative - -// the input field -.addtask-field - display: block - width: 100% +.task-add + margin-top: 1.618em + @extend $hrpg-button-with-input + hrpg-button-color-mixin($color-options-submenu) + input, button + height: 2.618em input - font-family: 'Lato', sans-serif - border: 0 - outline: 0 - border-radius: 0 - box-shadow: none - background-color: white - height: 3em - padding: 0 3.3em 0 0.5em - width: 100% - &:focus - box-shadow: inset 0 0 3px darken($best, 20%),inset -1px 0 1px darken($best, 30%) - -// the button -.addtask-btn - position: absolute - right: 0 - top: 0 - // important for overriding bootstrap, remove later - width: 1.81818em !important - height: 1.88em - padding: 0 - font-size: 1.61em - line-height: 1.7 - outline: 0 - border: 0 - border-radius: 0 //required to nuke BB-playbook user agent styles - background-color: darken($best, 15%) - &:hover, &:focus - background-color: darken($best, 20%) - &:active - background-color: darken($best, 30%) - + width: 80% + button + width: 20% + span + font-size: 0.8em // an individual task entry // ------------------------ @@ -428,22 +395,6 @@ form // todos ui // -------- -// nav tabs -.nav-tabs - margin-top: 1.5em -.nav-tabs > li - &.active > a - color: #333 - &:hover - margin-top: 1px - > a - border-radius: 0 !important - margin-top: 1px - color: #333 - &:hover - border-top: 0 - margin-top: 2px - // Checklists // -------- .checklist-form @@ -475,4 +426,34 @@ form margin: 0px 10px 3px 0px width: 4% li:hover .checklist-icons - opacity:1 \ No newline at end of file + opacity:1 + +// Task filters +// -------- +.task-filter + margin: 1.618em 0 1em 0 + hrpg-button-bar-mixin($color-tasks) + @extend $hrpg-button-bar + width: 100% + li + @extend $hrpg-button-master + hrpg-button-color-mixin($color-tasks) + width: 33.333% + text-align: center + &:nth-child(3n + 1):nth-last-child(2), + &:nth-child(3n + 1):nth-last-child(2) + li + width: 50%; + a + width: 100% + height: 100% +.rewards + .task-filter + li:nth-child(3n + 1):nth-last-child(2) + width: 33.333% + li:nth-child(3n + 1):nth-last-child(2) + li + width: 66.666% + +.repeat-days + a + border-radius: 0.382em + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index a5c04bdf80..adbcd0f6f8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -3,7 +3,7 @@ window.habitrpg = angular.module('habitrpg', ['ngResource', 'ngSanitize', 'userServices', 'groupServices', 'memberServices', 'challengeServices', 'authServices', 'notificationServices', 'guideServices', 'authCtrl', - 'ui.bootstrap', 'ui.keypress', 'ui.router', 'chieffancypants.loadingBar', 'At', 'pasvaz.bindonce', 'infinite-scroll', 'ui.select2']) + '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 // temporary hack until they have a better solution @@ -75,6 +75,11 @@ window.habitrpg = angular.module('habitrpg', templateUrl: "partials/options.social.html" }) + .state('options.social.inbox', { + url: "/inbox", + templateUrl: "partials/options.social.inbox.html" + }) + .state('options.social.tavern', { url: "/tavern", templateUrl: "partials/options.social.tavern.html", diff --git a/public/js/controllers/authCtrl.js b/public/js/controllers/authCtrl.js index 64bf201a9f..bacc41c28f 100644 --- a/public/js/controllers/authCtrl.js +++ b/public/js/controllers/authCtrl.js @@ -7,24 +7,13 @@ angular.module('authCtrl', []) .controller("AuthCtrl", ['$scope', '$rootScope', 'User', '$http', '$location', '$window','ApiUrlService', '$modal', function($scope, $rootScope, User, $http, $location, $window, ApiUrlService, $modal) { - var runAuth; - var showedFacebookMessage; - - $scope.useUUID = false; - $scope.toggleUUID = function() { - if (showedFacebookMessage === false) { - alert(window.env.t('untilNoFace')); - showedFacebookMessage = true; - } - $scope.useUUID = !$scope.useUUID; - }; $scope.logout = function() { localStorage.clear(); window.location.href = '/logout'; }; - runAuth = function(id, token) { + var runAuth = function(id, token) { User.authenticate(id, token, function(err) { $window.location.href = '/'; }); @@ -59,14 +48,10 @@ angular.module('authCtrl', []) username: $scope.loginUsername || $('#login-tab input[name="username"]').val(), password: $scope.loginPassword || $('#login-tab input[name="password"]').val() }; - if ($scope.useUUID) { - runAuth($scope.loginUsername, $scope.loginPassword); - } else { - $http.post(ApiUrlService.get() + "/api/v2/user/auth/local", data) - .success(function(data, status, headers, config) { - runAuth(data.id, data.token); - }).error(errorAlert); - } + $http.post(ApiUrlService.get() + "/api/v2/user/auth/local", data) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); }; $scope.playButtonClick = function(){ @@ -126,5 +111,22 @@ angular.module('authCtrl', []) $scope.hasNoNotifications = function() { return selectNotificationValue(false, false, false, false, true); } + + // ------ Social ---------- + + hello.init({ + facebook : window.env.FACEBOOK_KEY, + }); + + $scope.socialLogin = function(network){ + hello(network).login({scope:'email'}).then(function(auth){ + $http.post(ApiUrlService.get() + "/api/v2/user/auth/social", auth) + .success(function(data, status, headers, config) { + runAuth(data.id, data.token); + }).error(errorAlert); + }, function( e ){ + alert("Signin error: " + e.error.message ); + }); + } } ]); diff --git a/public/js/controllers/footerCtrl.js b/public/js/controllers/footerCtrl.js index 613ea531b0..9017602aec 100644 --- a/public/js/controllers/footerCtrl.js +++ b/public/js/controllers/footerCtrl.js @@ -31,7 +31,7 @@ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); - ga('create', window.env.GA_ID, 'habitrpg.com'); + ga('create', window.env.GA_ID, {userId:User.user._id}); ga('require', 'displayfeatures'); ga('send', 'pageview'); } @@ -39,7 +39,17 @@ // Scripts only for desktop if (!window.env.IS_MOBILE) { // Add This - $.getScript("//s7.addthis.com/js/250/addthis_widget.js#pubid=lefnire"); + //$.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 = { + services_custom:{ + name: "Download", + url: window.env.BASE_URL+"/export/avatar-"+User.user._id+".png", + icon: window.env.BASE_URL+"/favicon.ico" + }, + services_expanded:addthisServices, + services_compact:addthisServices + }; // Google Charts $.getScript("//www.google.com/jsapi", function() { diff --git a/public/js/controllers/groupsCtrl.js b/public/js/controllers/groupsCtrl.js index 335de42aa4..42ec3a421e 100644 --- a/public/js/controllers/groupsCtrl.js +++ b/public/js/controllers/groupsCtrl.js @@ -1,7 +1,7 @@ "use strict"; -habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', - function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state) { +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 @@ -45,8 +45,9 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } else { // We need the member information up top here, but then we pass it down to the modal controller // down below. Better way of handling this? - Members.selectMember(uid); - $rootScope.openModal('member', {controller:'MemberModalCtrl'}); + Members.selectMember(uid, function(){ + $rootScope.openModal('member', {controller:'MemberModalCtrl', windowClass:'profile-modal', size:'lg'}); + }); } } @@ -63,7 +64,7 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' } } - // ------ Invites ------ + // ------ Invites ------ $scope.invite = function(group){ Groups.Group.invite({gid: group._id, uuid: group.invitee}, undefined, function(){ @@ -72,11 +73,42 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' group.invitee = ''; }); } + + //var serializeQs = function(obj, prefix){ + // var str = []; + // for(var p in obj) { + // if (obj.hasOwnProperty(p)) { + // var k = prefix ? prefix + "[" + p + "]" : p, v = obj[p]; + // str.push(typeof v == "object" ? + // serializeQs(v, k) : + // encodeURIComponent(k) + "=" + encodeURIComponent(v)); + // } + // } + // return str.join("&"); + //} + // + //$scope.inviteLink = function(obj){ + // return window.env.BASE_URL + '?' + serializeQs({partyInvite: obj}); + //} + $scope.emails = [{name:"",email:""},{name:"",email:""}]; + $scope.inviter = User.user.profile.name; + $scope.inviteEmails = function(inviter, emails){ + $http.post('/api/v2/user/social/invite-friends', {inviter:inviter, emails:emails}).success(function(){ + Notification.text("Invitations sent!"); + $scope.emails = [{name:'',email:''},{name:'',email:''}]; + }); + } + + $scope.quickReply = function(uid) { + Members.selectMember(uid, function(){ + $rootScope.openModal('private-message',{controller:'MemberModalCtrl'}); + }); + } } ]) - .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', - function($scope, $rootScope, Members, Shared) { + .controller("MemberModalCtrl", ['$scope', '$rootScope', 'Members', 'Shared', '$http', 'Notification', + function($scope, $rootScope, Members, Shared, $http, Notification) { $scope.timestamp = function(timestamp){ return moment(timestamp).format('MM/DD/YYYY'); } @@ -86,6 +118,13 @@ habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', ' member.petCount = Shared.countPets(null, member.items.pets); $scope.profile = member; }); + $scope.sendPrivateMessage = function(uuid, message){ + $http.post('/api/v2/members/'+uuid+'/message',{message:message}).success(function(){ + Notification.text(window.env.t('messageSentAlert')); + $rootScope.User.sync(); + $scope.$close(); + }); + } } ]) diff --git a/public/js/controllers/rootCtrl.js b/public/js/controllers/rootCtrl.js index 1ef4aaeebc..4e27c81b52 100644 --- a/public/js/controllers/rootCtrl.js +++ b/public/js/controllers/rootCtrl.js @@ -16,6 +16,10 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ if (!!fromState.name) window.ga && ga('send', 'pageview', {page: '/#/'+toState.name}); + // clear inbox when entering or exiting inbox tab + if (fromState.name=='options.social.inbox' || toState.name=='options.social.inbox') { + User.user.ops.update && User.set({'inbox.newMessages':0}); + } }); $rootScope.User = User; @@ -113,8 +117,9 @@ habitrpg.controller("RootCtrl", ['$scope', '$rootScope', '$location', 'User', '$ controller: options.controller, // optional scope: options.scope, // optional keyboard: (options.keyboard === undefined ? true : options.keyboard), // optional - backdrop: (options.backdrop === undefined ? true : options.backdrop) // optional - + backdrop: (options.backdrop === undefined ? true : options.backdrop), // optional + size: options.size, // optional, 'sm' or 'lg' + windowClass: options.windowClass // optional }); } diff --git a/public/js/controllers/settingsCtrl.js b/public/js/controllers/settingsCtrl.js index b7a7aca592..0c0123d1f3 100644 --- a/public/js/controllers/settingsCtrl.js +++ b/public/js/controllers/settingsCtrl.js @@ -2,8 +2,8 @@ // Make user and settings available for everyone through root scope. habitrpg.controller('SettingsCtrl', - ['$scope', 'User', '$rootScope', '$http', 'ApiUrlService', 'Guide', '$location', '$timeout', 'Notification', - function($scope, User, $rootScope, $http, ApiUrlService, Guide, $location, $timeout, Notification) { + ['$scope', 'User', '$rootScope', '$http', 'ApiUrlService', 'Guide', '$location', '$timeout', 'Notification', 'Shared', + function($scope, User, $rootScope, $http, ApiUrlService, Guide, $location, $timeout, Notification, Shared) { // FIXME we have this re-declared everywhere, figure which is the canonical version and delete the rest // $scope.auth = function (id, token) { @@ -162,5 +162,22 @@ habitrpg.controller('SettingsCtrl', User.user.ops.release2({}); $rootScope.$state.go('tasks'); } + + // ---- Webhooks ------ + $scope._newWebhook = {url:''}; + $scope.$watch('user.preferences.webhooks',function(webhooks){ + $scope.hasWebhooks = _.size(webhooks); + }) + $scope.addWebhook = function(url) { + User.user.ops.addWebhook({body:{url:url, id:Shared.uuid()}}); + $scope._newWebhook.url = ''; + } + $scope.saveWebhook = function(id,webhook) { + delete webhook._editing; + User.user.ops.updateWebhook({params:{id:id}, body:webhook}); + } + $scope.deleteWebhook = function(id) { + User.user.ops.deleteWebhook({params:{id:id}}); + } } ]); diff --git a/public/js/services/memberServices.js b/public/js/services/memberServices.js index c24eaa5981..bb3a4c5ac5 100644 --- a/public/js/services/memberServices.js +++ b/public/js/services/memberServices.js @@ -57,7 +57,7 @@ angular.module('memberServices', ['ngResource', 'sharedServices']). * either gets them or fetches if not available * @param uid */ - selectMember: function(uid) { + selectMember: function(uid, cb) { var self = this; // Fetch from cache if we can. For guild members, only their uname will have been fetched on initial load, // check if they have full fields (eg, check profile.items and an item inside @@ -67,11 +67,13 @@ angular.module('memberServices', ['ngResource', 'sharedServices']). if (members[uid] && members[uid].items && members[uid].items.weapon) { Shared.wrap(members[uid],false); self.selectedMember = members[uid]; + cb(); } else { Member.get({uid: uid}, function(member){ self.populate(member); // lazy load for later Shared.wrap(member,false); self.selectedMember = members[member._id]; + cb(); }); } } diff --git a/public/js/static.js b/public/js/static.js index 84894ec5ab..860dbf5587 100644 --- a/public/js/static.js +++ b/public/js/static.js @@ -6,13 +6,29 @@ window.habitrpgStatic = angular.module('habitrpgStatic', ['notificationServices' .constant("STORAGE_SETTINGS_ID", 'habit-mobile-settings') .constant("MOBILE_APP", false) -habitrpgStatic.controller("PlansCtrl", ['$rootScope', +.controller("RootCtrl", ['$scope', '$location', '$modal', '$http', function($scope, $location, $modal, $http){ + // must be #?memberId=xx, see https://github.com/angular/angular.js/issues/7239 + var memberId = $location.search()['memberId']; + if (memberId) { + $http.get('/api/v2/members/'+memberId).success(function(data, status, headers, config){ + $scope.profile = data; + $scope.Content = window.habitrpgShared.content; + $modal.open({ + templateUrl: 'modals/member.html', + scope: $scope + }); + }) + } + }]) + +.controller("PlansCtrl", ['$rootScope', function($rootScope) { $rootScope.clickContact = function(){ window.ga && ga('send', 'event', 'button', 'click', 'Contact Us (Plans)'); } } ]) + .controller('AboutCtrl',[function(){ $(document).ready(function(){ $('a.gallery').colorbox({ diff --git a/public/manifest.json b/public/manifest.json index e44d2b2d0a..2e8a5b4ee1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -15,12 +15,13 @@ "bower_components/angular-loading-bar/build/loading-bar.js", "bower_components/Angular-At-Directive/src/at.js", "bower_components/Angular-At-Directive/src/caret.js", - "bower_components/angular-bindonce/bindonce.js", "bower_components/js-emoji/emoji.js", "bower_components/sticky/jquery.sticky.js", "bower_components/ngInfiniteScroll/build/ng-infinite-scroll.min.js", "bower_components/select2/select2.js", "bower_components/angular-ui-select2/src/select2.js", + "bower_components/hello/dist/hello.all.min.js", + "bower_components/angular-filter/dist/angular-filter.min.js", "bower_components/angular-bootstrap/ui-bootstrap.js", "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", @@ -66,6 +67,7 @@ ], "css": [ "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", "app.css", "bower_components/pnotify/jquery.pnotify.default.css", "bower_components/pnotify/jquery.pnotify.default.icons.css", @@ -83,6 +85,7 @@ "bower_components/angular-bootstrap/ui-bootstrap-tpls.js", "bower_components/bootstrap/dist/js/bootstrap.js", "bower_components/jquery-colorbox/jquery.colorbox-min.js", + "bower_components/hello/dist/hello.all.min.js", "bower_components/angular-loading-bar/build/loading-bar.js", "js/env.js", @@ -94,7 +97,10 @@ ], "css": [ "bower_components/bootstrap/dist/css/bootstrap.css", + "bower_components/css-social-buttons/css/zocial.css", "bower_components/jquery-colorbox/example1/colorbox.css", + "app.css", + "bower_components/habitrpg-shared/dist/habitrpg-shared.css", "static.css" ] } diff --git a/src/controllers/auth.js b/src/controllers/auth.js index eae795a3ea..8cf4b9a840 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -24,31 +24,6 @@ var accountSuspended = function(uuid){ }; } -var emailUser = function(name, email, emailType){ - request({ - url: nconf.get('EMAIL_SERVER_URL') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER_AUTH_USER'), - pass: nconf.get('EMAIL_SERVER_AUTH_PASSWORD') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: { - name: name, - email: email - } - }, - options: { - attemps: 5, - backoff: {delay: 10*60*1000, type: 'fixed'} - } - } - }); -} - api.auth = function(req, res, next) { var uid = req.headers['x-api-user']; var token = req.headers['x-api-key']; @@ -128,7 +103,7 @@ api.registerUser = function(req, res, next) { } user.save(cb); - if(isProd) emailUser(username, email, 'welcome'); + if(isProd) utils.txnEmail({name:username, email:email}, 'welcome'); ga.event('register', 'Local').send() } ], function(err, saved) { @@ -166,26 +141,58 @@ api.loginLocal = function(req, res, next) { }; /* - POST /user/auth/facebook + POST /user/auth/social */ +api.loginSocial = function(req, res, next) { + var access_token = req.body.authResponse.access_token, + network = req.body.network; + if (network!=='facebook') + return res.json(401, {err:"Only Facebook supported currently."}); + async.waterfall([ + 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); + // Create new user + var prof = data.profile; + var user = { + preferences: { + language: req.language // User language detected from browser, not saved + }, + auth: { + timestamps: {created: +new Date(), loggedIn: +new Date()} + } + }; + user.auth[network] = prof; + user = new User(user); + user.save(cb); - -api.loginFacebook = function(req, res, next) { - var facebook_id = req.body.facebook_id; - if (!facebook_id) return res.json(401, {err: 'No facebook id provided'}); - User.findOne({'auth.facebook.id': facebook_id}, function(err, user) { - if (err) { - return res.json(401, {err: err}); - } else if (user) { - if (user.auth.blocked) return res.json(401, accountSuspended(user._id)); - return res.json(200, {id: user.id,token: user.apiToken}); - } else { - /* FIXME: create a new user instead*/ - return res.json(403, {err: "Please register with Facebook on https://habitrpg.com, then come back here and log in."}); + 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){ + 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}); + }) }; +/** + * DELETE /user/auth/social + * TODO implement + */ +api.deleteSocial = function(req,res,next){next()} + api.resetPassword = function(req, res, next){ var email = req.body.email, salt = utils.makeSalt(), @@ -271,66 +278,4 @@ api.setupPassport = function(router) { res.redirect('/'); }) - // GET /auth/facebook - // Use passport.authenticate() as route middleware to authenticate the - // request. The first step in Facebook authentication will involve - // redirecting the user to facebook.com. After authorization, Facebook will - // redirect the user back to this application at /auth/facebook/callback - router.get('/auth/facebook', - passport.authenticate('facebook', {scope: 'email'}), - i18n.getUserLanguage, - function(req, res){ - // The request will be redirected to Facebook for authentication, so this - // function will not be called. - }); - - // GET /auth/facebook/callback - // Use passport.authenticate() as route middleware to authenticate the - // request. If authentication fails, the user will be redirected back to the - // login page. Otherwise, the primary route function function will be called, - // which, in this example, will redirect the user to the home page. - router.get('/auth/facebook/callback', - passport.authenticate('facebook', { failureRedirect: '/login' }), - i18n.getUserLanguage, - function(req, res) { - //res.redirect('/'); - - async.waterfall([ - function(cb){ - User.findOne({'auth.facebook.id':req.user.id}, cb) - }, - function(user, cb){ - if (user) return cb(null, user); - - user = new User({ - preferences: { - language: req.language // User language detected from browser, not saved - }, - auth: { - facebook: req.user, - timestamps: {created: +new Date(), loggedIn: +new Date()} - } - }); - user.save(cb); - if(isProd && req.user.emails && req.user.emails[0] && req.user.emails[0].value){ - emailUser((req.user.displayName || req.user.username), req.user.emails[0].value, 'welcome'); - } - ga.event('register', 'Facebook').send() - } - ], function(err, saved){ - if (err) return res.redirect('/static/front?err=' + err); - req.session.userId = saved._id; - res.redirect('/static/front?_id='+saved._id+'&apiToken='+saved.apiToken); - }) - }); - - // Simple route middleware to ensure user is authenticated. - // Use this route middleware on any resource that needs to be protected. If - // the request is authenticated (typically via a persistent login session), - // the request will proceed. Otherwise, the user will be redirected to the - // login page. -// function ensureAuthenticated(req, res, next) { -// if (req.isAuthenticated()) { return next(); } -// res.redirect('/login') -// } }; diff --git a/src/controllers/dataexport.js b/src/controllers/dataexport.js index 48a08b1d38..8a48ee1638 100644 --- a/src/controllers/dataexport.js +++ b/src/controllers/dataexport.js @@ -8,6 +8,13 @@ var js2xmlparser = require("js2xmlparser"); var pd = require('pretty-data').pd; var User = require('../models/user').model; +// Avatar screenshot/static-page includes +var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres +var AWS = require('aws-sdk'); +AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); +var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream +var bucket = nconf.get("S3:bucket"); +var request = require('request'); /* ------------------------------------------------------------------------ @@ -83,3 +90,49 @@ expressres.jsonstring = function(obj, headers, status) { body = pd.json(JSON.stringify(obj)); return this.send(body, headers, status); }; + +/* + ------------------------------------------------------------------------ + Static page and image screenshot of avatar + ------------------------------------------------------------------------ + */ + + +dataexport.avatarPage = function(req, res) { + User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){ + res.render('avatar-static', { + title: user.profile.name, + env: _.defaults({user:user},res.locals.habitrpg) + }); + }) +}; + +dataexport.avatarImage = function(req, res, next) { + var filename = 'avatar-'+req.params.uuid+'.png'; + request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) { + // cache images for 10 minutes on aws, else upload a new one + if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) + return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename); + new Pageres()//{delay:1} + .src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')}) + .run(function (err, file) { + if (err) return next(err); + // see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createMultipartUpload-property + var upload = s3Stream.upload({ + Bucket: bucket, + Key: filename, + ACL: "public-read", + StorageClass: "REDUCED_REDUNDANCY", + ContentType: "image/png", + Expires: +moment().add({minutes: 3}) + }); + upload.on('error', function (err) { + next(err); + }); + upload.on('uploaded', function (details) { + res.redirect(details.Location); + }); + file[0].pipe(upload); + }); + }) +}; diff --git a/src/controllers/groups.js b/src/controllers/groups.js index cd7c768fad..50272cdab2 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -19,7 +19,7 @@ var api = module.exports; ------------------------------------------------------------------------ */ -var partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; +var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; var nameFields = 'profile.name'; var challengeFields = '_id name'; var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }; @@ -44,15 +44,6 @@ var populateQuery = function(type, q){ return q; } - -api.getMember = function(req, res, next) { - User.findById(req.params.uid).select(partyFields).exec(function(err, user){ - if (err) return next(err); - if (!user) return res.json(400,{err:'User not found'}); - res.json(user); - }) -} - /** * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually * as /groups/party or /groups/tavern @@ -300,7 +291,8 @@ api.join = function(req, res, next) { group = res.locals.group; if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { - user.invitations.party = undefined; + User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter + user.invitations.party = undefined; // Clear invite user.save(); // invite new user to pending quest if (group.quest.key && !group.quest.active) { @@ -439,10 +431,10 @@ api.invite = function(req, res, next) { function sendInvite (){ if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name}); + invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); }else{ //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name} + invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; } group.invites.push(invite._id); diff --git a/src/controllers/hall.js b/src/controllers/hall.js index 6fc104b0a1..cd8eba7744 100644 --- a/src/controllers/hall.js +++ b/src/controllers/hall.js @@ -55,12 +55,12 @@ api.updateHero = function(req,res,next) { function(member, cb){ if (!member) return res.json(404, {err: "User not found"}); member.balance = req.body.balance || 0; - newTier = req.body.contributor.level; // tier = level in this context - oldTier = member.contributor && member.contributor.level || 0; + var newTier = req.body.contributor.level; // tier = level in this context + var oldTier = member.contributor && member.contributor.level || 0; if (newTier > oldTier) { member.flags.contributor = true; - gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff - tierDiff = newTier - oldTier; // can be 2+ tier increases at once + var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff + var tierDiff = newTier - oldTier; // can be 2+ tier increases at once while (tierDiff) { member.balance += gemsPerTier[newTier] / 4; // balance is in $ tierDiff--; @@ -71,7 +71,7 @@ api.updateHero = function(req,res,next) { member.purchased.ads = req.body.purchased.ads; if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5; if (req.body.itemPath && req.body.itemVal - && req.body.itemPath.indexOf('items.')===0 + && req.body.itemPath.indexOf('items.') === 0 && User.schema.paths[req.body.itemPath]) { shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary) } diff --git a/src/controllers/members.js b/src/controllers/members.js new file mode 100644 index 0000000000..9d3017960f --- /dev/null +++ b/src/controllers/members.js @@ -0,0 +1,50 @@ +var User = require('mongoose').model('User'); +var groups = require('../models/group'); +var partyFields = require('./groups').partyFields +var api = module.exports; +var async = require('async'); +var _ = require('lodash'); +var shared = require('habitrpg-shared'); + +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); + }) +} + +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'}); + 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."}); + } + + 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); + } + ], function(err){ + if (err) return err.code ? res.json(err.code,{err:err.err}) : err; + res.send(200); + }) + +} \ No newline at end of file diff --git a/src/controllers/payments.js b/src/controllers/payments.js deleted file mode 100644 index 47aad0e85d..0000000000 --- a/src/controllers/payments.js +++ /dev/null @@ -1,324 +0,0 @@ -/* @see ./routes.coffee for routing*/ - -var _ = require('lodash'); -var logger = require('../logging'); -var ipn = require('paypal-ipn'); -var shared = require('habitrpg-shared'); -var nconf = require('nconf'); -var async = require('async'); -var User = require('./../models/user').model; -var ga = require('./../utils').ga; -var logging = require('./../logging'); -var userAPI = require('./user'); -var request = require('request'); -var moment = require('moment'); -var api = module.exports; -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); - -var PaypalRecurring = require('paypal-recurring'); -var paypalRecurring = new PaypalRecurring({ - username: nconf.get('PAYPAL_USERNAME'), - password: nconf.get('PAYPAL_PASSWORD'), - signature: nconf.get('PAYPAL_SIGNATURE') -}, isProduction ? "production" : "sandbox"); -var paypalCheckout = require('paypal-express-checkout') - .init(nconf.get('PAYPAL_USERNAME'), nconf.get('PAYPAL_PASSWORD'), nconf.get('PAYPAL_SIGNATURE'), nconf.get('BASE_URL'), nconf.get('BASE_URL'), !isProduction); - -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { - if ( - item.klass === 'mystery' && - moment().isAfter(item.mystery.start) && - moment().isBefore(item.mystery.end) && - !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -function getMailingInfo(user) { - var email, name; - if(user.auth.local){ - email = user.auth.local.email; - name = user.profile.name || user.auth.local.username; - }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ - email = user.auth.facebook.emails[0].value; - name = user.auth.facebook.displayName || user.auth.facebook.username; - } - return {'email': email, 'name': name}; -} - -function emailUser(user, emailType) { - var mailingInfo = getMailingInfo(user); - if(mailingInfo.email){ - request({ - url: nconf.get('EMAIL_SERVER_URL') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER_AUTH_USER'), - pass: nconf.get('EMAIL_SERVER_AUTH_PASSWORD') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: { - name: mailingInfo.name, - email: mailingInfo.email - } - }, - options: { - attemps: 5, - backoff: {delay: (10*60*1000), type: 'fixed'} - } - } - }); - } -} - -function createSubscription(user, data) { - if (!user.purchased.plan) user.purchased.plan = {}; - _(user.purchased.plan) - .merge({ // override with these values - planId:'basic_earned', - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - dateTerminated: null - }) - .defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }); - revealMysteryItems(user); - if(isProduction) emailUser(user, 'subscription-begins'); - user.purchased.txnCount++; - ga.event('subscribe', data.paymentMethod).send(); - ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod + " > Stripe").send(); -} - -/** - * Sets their subscription to be cancelled later - */ -function cancelSubscription(user, data){ - var du = user.purchased.plan.dateUpdated, now = moment(); - if(isProduction) emailUser(user, 'cancel-subscription'); - user.purchased.plan.dateTerminated = - moment( now.format('MM') + '/' + moment(du).format('DD') + '/' + now.format('YYYY') ) - .add({months:1}) - .toDate(); - ga.event('unsubscribe', 'Stripe').send(); - -} - -function buyGems(user, data) { - user.balance += 5; - user.purchased.txnCount++; - if(isProduction) emailUser(user, 'donation'); - ga.event('checkout', data.paymentMethod).send(); - ga.transaction(data.customerId, 5).item(5, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); -} - -// Expose some functions for tests -if (nconf.get('NODE_ENV')==='testing') { - api.cancelSubscription = cancelSubscription; - api.createSubscription = createSubscription; -} - -/* - Setup Stripe response when posting payment - */ -api.stripeCheckout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - - async.waterfall([ - function(cb){ - if (req.query.plan) { - stripe.customers.create({ - email: req.body.email, - metadata: {uuid: res.locals.user._id}, - card: token, - plan: req.query.plan, - }, cb); - } else { - stripe.charges.create({ - amount: "500", // $5 - currency: "usd", - card: token - }, cb); - } - }, - function(response, cb) { - if (req.query.plan) { - createSubscription(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } else { - buyGems(user, {customerId: response.id, paymentMethod: 'Stripe'}); - } - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - user = token = null; - }); -}; - -api.stripeSubscribeCancel = 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) { - stripe.customers.del(user.purchased.plan.customerId, cb); - }, - function(response, cb) { - cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -api.stripeSubscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; - - async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); - }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); - }, - function(response, cb) { - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - token = user = user_id = sub_id; - }); -}; - -api.paypalSubscribe = function(req,res,next) { - // Authenticate a future subscription of ~5 USD - paypalRecurring.authenticate({ - RETURNURL: nconf.get('BASE_URL') + '/paypal/subscribe/success?uuid=' + res.locals.user._id, - CANCELURL: nconf.get("BASE_URL"), - PAYMENTREQUEST_0_AMT: 5, - L_BILLINGAGREEMENTDESCRIPTION0: "HabitRPG Subscription" - }, function(err, data, url) { - // Redirect the user if everything went well with - // a HTTP 302 according to PayPal's guidelines - if (err) return next(err); - res.redirect(302, url); - }); -}; - -api.paypalSubscribeSuccess = function(req,res,next) { - // Create a subscription of 10 USD every month - var uuid = req.query.uuid; - if (!uuid) return next("UUID required"); - paypalRecurring.createSubscription(req.query.token, req.query.PayerID,{ - AMT: 5, - DESC: "HabitRPG Subscription", - BILLINGPERIOD: "Month", - BILLINGFREQUENCY: 1, - }, function(err, data) { - if (err) return res.next(err); - User.findById(uuid, function(err,user){ - if (err) return next(err); - createSubscription(user, {customerId: data.PROFILEID, paymentMethod: 'Paypal'}); - user.save(function(err,saved){ - res.redirect('/'); - }) - }) - }); -}; - -api.paypalSubscribeCancel = 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) { - paypalRecurring.modifySubscription(user.purchased.plan.customerId, 'cancel', cb); - }, - function(response, cb) { - cancelSubscription(user); - user.save(cb); - } - ], function(err, saved){ - if (err) return next(err); - res.redirect('/'); - user = null; - }); -}; - -api.paypalCheckout = function(req, res, next) { - var opts = {RETURNURL:nconf.get('BASE_URL') + '/paypal/checkout/success?uuid=' + res.locals.user._id}; - paypalCheckout.pay(+new Date(), 5, 'HabitRPG Gems', 'USD', opts, function(err, url) { - if (err) return next(err); - res.redirect(url); - }); -}; - -api.paypalCheckoutSuccess = function(req,res,next) { - paypalCheckout.detail(req.query.token, req.query.PayerID, function(err, data, invoiceNumber, price) { - // see `data` vars at https://github.com/petersirka/node-paypal-express-checkout#paypal-account - //if (err) return next('PayPal Error: ' + msg); - if (err) return next(err); - if (data.ACK !== 'Success') return next('PayPal transaction failed, please try again'); - - var uuid = req.query.uuid; //, apiToken = query.apiToken; - User.findById(uuid , function(err, user) { - if (_.isEmpty(user)) err = "user not found with uuid " + uuid + " when completing paypal transaction"; - if (err) return next(err); - buyGems(user, {customerId:req.query.PayerID, paymentMethod:'Paypal'}); - user.save(function(){ - if (err) return next(err); - res.redirect('/'); - uuid = null; - }); - }); - }); -}; - -/** - * General IPN handler. We could use this for all paypal transaction handling (instead of the above functions), but I've - * found it extremely unreliable. Instead, here we'll cancel HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments. - */ -api.paypalIPN = function(req, res, next) { - // Must respond to PayPal IPN request with an empty 200 first, if using Express uncomment the following: - res.send(200); - ipn.verify(req.body, function callback(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // 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': - 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) - cancelSubscription(user); - user.save(); - }); - break; - } - }); -}; diff --git a/src/controllers/payments/index.js b/src/controllers/payments/index.js new file mode 100644 index 0000000000..ba6359d4f9 --- /dev/null +++ b/src/controllers/payments/index.js @@ -0,0 +1,77 @@ +/* @see ./routes.coffee for routing*/ +var _ = require('lodash'); +var shared = require('habitrpg-shared'); +var nconf = require('nconf'); +var utils = require('./../../utils'); +var moment = require('moment'); +var isProduction = nconf.get("NODE_ENV") === "production"; +var stripe = require('./stripe'); +var paypal = require('./paypal'); + +function revealMysteryItems(user) { + _.each(shared.content.gear.flat, function(item) { + if ( + item.klass === 'mystery' && + moment().isAfter(item.mystery.start) && + moment().isBefore(item.mystery.end) && + !user.items.gear.owned[item.key] && + !~user.purchased.plan.mysteryItems.indexOf(item.key) + ) { + user.purchased.plan.mysteryItems.push(item.key); + } + }); +} + +exports.createSubscription = function(user, data) { + if (!user.purchased.plan) user.purchased.plan = {}; + _(user.purchased.plan) + .merge({ // override with these values + planId:'basic_earned', + customerId: data.customerId, + dateUpdated: new Date(), + gemsBought: 0, + paymentMethod: data.paymentMethod, + dateTerminated: null + }) + .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(); +} + +/** + * 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}) + .toDate(); + utils.ga.event('unsubscribe', 'Stripe').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.stripeCheckout = stripe.checkout; +exports.stripeSubscribeCancel = stripe.subscribeCancel; +exports.stripeSubscribeEdit = stripe.subscribeEdit; + +exports.paypalSubscribe = paypal.createBillingAgreement; +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 diff --git a/src/controllers/payments/paypal.js b/src/controllers/payments/paypal.js new file mode 100644 index 0000000000..cfc0b8d4d2 --- /dev/null +++ b/src/controllers/payments/paypal.js @@ -0,0 +1,166 @@ +var nconf = require('nconf'); +var moment = require('moment'); +var async = require('async'); +var _ = require('lodash'); +var url = require('url'); +var mongoose = require('mongoose'); +var payments = require('./index'); +var logger = require('../../logging'); +var ipn = require('paypal-ipn'); +var paypal = require('paypal-rest-sdk'); + +// 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'); + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + 'client_secret': nconf.get("PAYPAL:client_secret") +}); + +var parseErr = function(err){ + return (err.response && err.response.message || err.response.details[0].issue) || err; +} + +exports.createBillingAgreement = function(req,res,next){ + var billingPlanTitle ="HabitRPG subscription ($5 month-to-month)"; + var billingAgreementAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "start_date": moment().add({seconds:5}).format(), + "plan": { + "id": billingPlanID + }, + "payer": { + "payment_method": "paypal" + } + }; + paypal.billingAgreement.create(billingAgreementAttributes, function (err, billingAgreement) { + if (err) return next(parseErr(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); + }); +} + +exports.executeBillingAgreement = function(req,res,next){ + async.waterfall([ + function(cb){ + paypal.billingAgreement.execute(req.query.token, {}, cb); + }, + function(billingAgreement, cb){ + mongoose.model('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'}); + data.user.save(cb); + } + ],function(err){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +exports.createPayment = function(req, res, next) { + var create_payment = { + "intent": "sale", + "payer": { + "payment_method": "paypal" + }, + "redirect_urls": { + "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', + "cancel_url": nconf.get('BASE_URL') + }, + "transactions": [{ + "item_list": { + "items": [{ + "name": "HabitRPG Gems", + //"sku": "1", + "price": "5.00", + "currency": "USD", + "quantity": 1 + }] + }, + "amount": { + "currency": "USD", + "total": "5.00" + }, + "description": "HabitRPG Gems" + }] + }; + paypal.payment.create(create_payment, function (err, payment) { + if (err) return next(parseErr(err)); + var link = _.find(payment.links, {rel: 'approval_url'}).href; + res.redirect(link); + }); +} + +exports.executePayment = function(req, res, next) { + var paymentId = req.query.paymentId, + PayerID = req.query.PayerID; + async.waterfall([ + function(cb){ + paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); + }, + function(payment, cb){ + mongoose.model('User').findById(req.session.userId, 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(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + }) +} + +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); + }, + function(response, cb) { + payments.cancelSubscription(user); + user.save(cb); + } + ], function(err, saved){ + if (err) return next(parseErr(err)); + res.redirect('/'); + user = null; + }); +} + +/** + * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their + * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution + */ +exports.ipn = function(req, res, next) { + console.log('IPN Called'); + res.send(200); // Must respond to PayPal IPN request with an empty 200 first + ipn.verify(req.body, function(err, msg) { + if (err) return logger.error(msg); + switch (req.body.txn_type) { + // 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){ + 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(); + }); + break; + } + }); +}; + diff --git a/src/controllers/payments/paypalBillingSetup.js b/src/controllers/payments/paypalBillingSetup.js new file mode 100644 index 0000000000..d4d7f8e167 --- /dev/null +++ b/src/controllers/payments/paypalBillingSetup.js @@ -0,0 +1,68 @@ +// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring +// 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 + +var path = require('path'); +var nconf = require('nconf'); +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 + +paypal.configure({ + 'mode': nconf.get("PAYPAL:mode"), //sandbox or live + 'client_id': nconf.get("PAYPAL:client_id"), + '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 billingPlanAttributes = { + "name": billingPlanTitle, + "description": billingPlanTitle, + "type": "INFINITE", + "merchant_preferences": { + "auto_bill_amount": "yes", + "cancel_url": nconf.get("BASE_URL"), + "return_url": nconf.get('BASE_URL') + '/paypal/subscribe/success' + }, + "payment_definitions": [{ + "name": billingPlanTitle, + "type": "REGULAR", + "frequency_interval": "1", + "frequency": "MONTH", + "cycles": "0", + "amount": { + "currency": "USD", + "value": "5" + } + }] +}; + +switch(OP) { + case "list": + paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ + console.log({err:err, plans:plans}); + }); + break; + case "update": + break; + case "create": + paypal.billingPlan.create(billingPlanAttributes, function(err,plan){ + if (plan.state == "ACTIVE") + return console.log({err:err, plan:plan}); + var billingPlanUpdateAttributes = [{ + "op": "replace", + "path": "/", + "value": { + "state": "ACTIVE" + } + }]; + // Activate the plan by changing status to Active + paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ + console.log({err:err, response:response}); + }); + }); + case "remove": + break; +} \ No newline at end of file diff --git a/src/controllers/payments/stripe.js b/src/controllers/payments/stripe.js new file mode 100644 index 0000000000..4f9a52afeb --- /dev/null +++ b/src/controllers/payments/stripe.js @@ -0,0 +1,89 @@ +var nconf = require('nconf'); +var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); +var async = require('async'); +var payments = require('./index'); + +/* + Setup Stripe response when posting payment + */ +exports.checkout = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + + async.waterfall([ + function(cb){ + if (req.query.plan) { + stripe.customers.create({ + email: req.body.email, + metadata: {uuid: res.locals.user._id}, + card: token, + plan: req.query.plan + }, cb); + } else { + stripe.charges.create({ + amount: "500", // $5 + 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); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + user = token = null; + }); +}; + +exports.subscribeCancel = 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) { + stripe.customers.del(user.purchased.plan.customerId, cb); + }, + function(response, cb) { + payments.cancelSubscription(user); + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.redirect('/'); + user = null; + }); +}; + +exports.subscribeEdit = function(req, res, next) { + var token = req.body.id; + var user = res.locals.user; + var user_id = user.purchased.plan.customerId; + var sub_id; + + async.waterfall([ + function(cb){ + stripe.customers.listSubscriptions(user_id, cb); + }, + function(response, cb) { + sub_id = response.data[0].id; + console.warn(sub_id); + console.warn([user_id, sub_id, { card: token }]); + stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); + }, + function(response, cb) { + user.save(cb); + } + ], function(err, saved){ + if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors + res.send(200); + token = user = user_id = sub_id; + }); +}; \ No newline at end of file diff --git a/src/controllers/user.js b/src/controllers/user.js index e2e7ec6b38..a1c71908a0 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -7,13 +7,16 @@ var nconf = require('nconf'); var async = require('async'); var shared = require('habitrpg-shared'); var User = require('./../models/user').model; -var ga = require('./../utils').ga; +var utils = require('./../utils'); +var ga = utils.ga; var Group = require('./../models/group').model; var Challenge = require('./../models/challenge').model; var moment = require('moment'); var logging = require('./../logging'); var acceptablePUTPaths; var api = module.exports; +var qs = require('qs'); +var request = require('request'); // api.purchase // Shared.ops @@ -106,6 +109,15 @@ api.score = function(req, res, next) { _tmp: user._tmp }, saved.toJSON().stats)); + // Webhooks + _.each(user.preferences.webhooks, function(h){ + request.post({ + url: h.url, + //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" + body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true + }); + }); + if ( (!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there || (task.type == 'reward') // we don't want to update the reward GP cost @@ -204,7 +216,7 @@ api.getUser = function(req, res, next) { * FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations */ acceptablePUTPaths = _.reduce(require('./../models/user').schema.paths, function(m,v,leaf){ - var found= _.find('achievements filters flags invitations lastCron party preferences profile stats'.split(' '), function(root){ + var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){ return leaf.indexOf(root) == 0; }); if (found) m[leaf]=true; @@ -393,6 +405,51 @@ api.cast = function(req, res, next) { } } +/** + * POST /user/invite-friends + */ +api.inviteFriends = function(req, res, next) { + Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){ + if (err) return next(err); + var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); + _.each(req.body.emails, function(invite){ + if (invite.email) { + var variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || res.locals.user.profile.name}, + {name: 'INVITEE', content: invite.name} + ]; + // TODO implement "users can only be invited once" + utils.txnEmail(invite, 'invite-friend', variables); + } + }); + res.send(200); + }) +} + +api.sessionPartyInvite = function(req,res,next){ + if (!req.session.partyInvite) return next(); + var inv = res.locals.user.invitations; + if (inv.party && inv.party.id) return next(); // already invited to a party + async.waterfall([ + function(cb){ + Group.findOne({_id:req.session.partyInvite.id, type:'party', members:{$in:[req.session.partyInvite.inviter]}}) + .select('invites members').exec(cb); + }, + function(group, cb){ + if (!group) return cb("Inviter not in party"); + inv.party = req.session.partyInvite; + delete req.session.partyInvite; + if (!~group.invites.indexOf(res.locals.user._id)) + group.invites.push(res.locals.user._id); //$addToSt + group.save(cb); + }, + function(saved, cb){ + res.locals.user.save(cb); + } + ], next); +} + /** * All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping */ diff --git a/src/middleware.js b/src/middleware.js index 1781ef2394..ba5ed01f07 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -12,6 +12,7 @@ var shared = require('habitrpg-shared'); var request = require('request'); var os = require('os'); var moment = require('moment'); +var utils = require('./utils'); module.exports.apiThrottle = function(app) { if (nconf.get('NODE_ENV') !== 'production') return; @@ -192,10 +193,16 @@ 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{ + req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite)) + } catch(e){} next(); } diff --git a/src/models/group.js b/src/models/group.js index 2f8f6674b9..fd2518b8ed 100644 --- a/src/models/group.js +++ b/src/models/group.js @@ -93,7 +93,7 @@ GroupSchema.methods.toJSON = function(){ return doc; } -var chatDefaults = function(message,user){ +var chatDefaults = module.exports.chatDefaults = function(message,user){ var message = { id: shared.uuid(), text: message, diff --git a/src/models/user.js b/src/models/user.js index 263207d48f..2be3e9fd95 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -117,6 +117,9 @@ var UserSchema = new Schema({ freeRebirth: {type: Boolean, 'default': false}, levelDrops: {type:Schema.Types.Mixed, 'default':{}}, chatRevoked: Boolean, + // Used to track the status of recapture emails sent to each user, + // can be 0 - no email sent - 1, 2 or 3 - 3 means no more email will be sent to the user + recaptureEmailsPhase: {type: Number, 'default': 0}, communityGuidelinesAccepted: {type: Boolean, 'default': false} }, history: { @@ -274,7 +277,8 @@ var UserSchema = new Schema({ tagsCollapsed: {type: Boolean, 'default': false}, advancedCollapsed: {type: Boolean, 'default': false}, toolbarCollapsed: {type:Boolean, 'default':false}, - background: String + background: String, + webhooks: {type: Schema.Types.Mixed, 'default': {}} }, profile: { blurb: String, @@ -322,6 +326,13 @@ var UserSchema = new Schema({ challenges: [{type: 'String', ref:'Challenge'}], + inbox: { + newMessages: {type:Number, 'default':0}, + blocks: {type:Array, 'default':[]}, + messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist + optOut: {type:Boolean, 'default':false} + }, + habits: {type:[TaskSchemas.HabitSchema]}, dailys: {type:[TaskSchemas.DailySchema]}, todos: {type:[TaskSchemas.TodoSchema]}, @@ -460,6 +471,10 @@ UserSchema.methods.unlink = function(options, cb) { module.exports.schema = UserSchema; module.exports.model = mongoose.model("User", UserSchema); -mongoose.model("User").find({$query:{'contributor.admin':true}, $orderby:{'contributor.level':-1, 'backer.npc':-1, 'profile.name':1}},function(err,mods){ - module.exports.mods = mods +mongoose.model("User") + .find({'contributor.admin':true}) + .sort('-contributor.level -backer.npc profile.name') + .select('profile contributor backer') + .exec(function(err,mods){ + module.exports.mods = mods }); diff --git a/src/routes/apiv2.coffee b/src/routes/apiv2.coffee index edacbdc243..00191a52b1 100644 --- a/src/routes/apiv2.coffee +++ b/src/routes/apiv2.coffee @@ -10,6 +10,7 @@ $ mocha test/user.mocha.coffee user = require("../controllers/user") groups = require("../controllers/groups") +members = require("../controllers/members") auth = require("../controllers/auth") hall = require("../controllers/hall") challenges = require("../controllers/challenges") @@ -320,7 +321,7 @@ module.exports = (swagger, v2) -> parameters:[ body '','The array of batch-operations to perform','object' ] - middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron] + middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite] action: user.batchUpdate # Tags @@ -364,6 +365,46 @@ module.exports = (swagger, v2) -> ] action: user.deleteTag + "/user/social/invite-friends": + spec: + method: 'POST' + description: 'Invite friends via email' + parameters: [ + body 'invites','Array of [{name:"Friend\'s Name", email:"friends@email.com"}] to invite to play in your party','object' + ] + action: user.inviteFriends + + # Webhooks + "/user/webhooks": + spec: + method: 'POST' + description: 'Create a new webhook' + parameters: [ + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.addWebhook + + "/user/webhooks/{id}:PUT": + spec: + path: '/user/webhooks/{id}' + method: 'PUT' + description: "Edit a webhook" + parameters: [ + path 'id','The id of the webhook to edit','string' + body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' + ] + action: user.updateWebhook + + "/user/webhooks/{id}:DELETE": + spec: + path: '/user/webhooks/{id}' + method: 'DELETE' + description: 'Delete a webhook' + parameters: [ + path 'id','Id of webhook to delete','string' + ] + action: user.deleteWebhook + # --------------------------------- # Groups # --------------------------------- @@ -539,9 +580,28 @@ module.exports = (swagger, v2) -> # --------------------------------- # Members # --------------------------------- - "/members/{uid}": + "/members/{uuid}": spec:{} - action: groups.getMember + action: members.getMember + "/members/{uuid}/message": + spec: + method: 'POST' + description: 'Send a private message to a member' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + body '', '{message: "The private message to send"}', 'object' + ] + middleware: [auth.auth] + action: members.sendPrivateMessage + "/members/{uuid}/block": + spec: + method: 'POST' + description: 'Block a member from sending private messages' + parameters: [ + path 'uuid', 'The UUID of the member to message', 'string' + ] + middleware: [auth.auth] + action: user.blockUser # --------------------------------- # Hall of Heroes / Patrons diff --git a/src/routes/auth.js b/src/routes/auth.js index 616f9cf7d6..0405154db1 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -7,13 +7,13 @@ var router = new express.Router(); auth.setupPassport(router); //FIXME make this consistent with the others router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v2/user/auth/facebook', i18n.getUserLanguage, auth.loginFacebook); +router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v1/user/auth/facebook', i18n.getUserLanguage, auth.loginFacebook); +router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); module.exports = router; \ No newline at end of file diff --git a/src/routes/dataexport.js b/src/routes/dataexport.js index ad2e2797d0..ba27957124 100644 --- a/src/routes/dataexport.js +++ b/src/routes/dataexport.js @@ -3,11 +3,14 @@ var router = new express.Router(); var dataexport = require('../controllers/dataexport'); var auth = require('../controllers/auth'); var nconf = require('nconf'); -var i18n = require('../i18n') +var i18n = require('../i18n'); +var middleware = require('../middleware.js'); /* Data export */ router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); +router.get('/avatar-:uuid.html', i18n.getUserLanguage, middleware.locals, dataexport.avatarPage); +router.get('/avatar-:uuid.png', i18n.getUserLanguage, middleware.locals, dataexport.avatarImage); module.exports = router; diff --git a/src/server.js b/src/server.js index 064b88d5a6..aed0b524b6 100644 --- a/src/server.js +++ b/src/server.js @@ -10,9 +10,8 @@ var isDev = nconf.get('NODE_ENV') === 'development'; if (cluster.isMaster && (isDev || isProd)) { // Fork workers. - _.times(_.min([require('os').cpus().length,2]), function(){ - cluster.fork(); - }); + var cpus = require('os').cpus(); + _.times(nconf.get("HEROKU_PROD") ? (cpus.length-1 || 1) : (_.min([cpus.length,2])), cluster.fork); cluster.on('disconnect', function(worker, code, signal) { var w = cluster.fork(); // replace the dead worker @@ -26,6 +25,7 @@ if (cluster.isMaster && (isDev || isProd)) { var path = require("path"); var swagger = require("swagger-node-express"); var autoinc = require('mongoose-id-autoinc'); + var shared = require('habitrpg-shared'); // Setup translations var i18n = require('./i18n'); @@ -65,32 +65,23 @@ if (cluster.isMaster && (isDev || isProd)) { // have a database of user records, the complete Facebook profile is serialized // and deserialized. passport.serializeUser(function(user, done) { - done(null, user); + done(null, user); }); passport.deserializeUser(function(obj, done) { - done(null, obj); + done(null, obj); }); - // Use the FacebookStrategy within Passport. - // Strategies in Passport require a `verify` function, which accept - // credentials (in this case, an accessToken, refreshToken, and Facebook - // profile), and invoke a callback with a user object. + // FIXME + // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) + // The proper fix would be to move to a general OAuth module simply to verify accessTokens passport.use(new FacebookStrategy({ - clientID: nconf.get("FACEBOOK_KEY"), - clientSecret: nconf.get("FACEBOOK_SECRET"), - callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" + clientID: nconf.get("FACEBOOK_KEY"), + clientSecret: nconf.get("FACEBOOK_SECRET"), + //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" }, function(accessToken, refreshToken, profile, done) { - // asynchronous verification, for effect... - //process.nextTick(function () { - - // To keep the example simple, the user's Facebook profile is returned to - // represent the logged-in user. In a typical application, you would want - // to associate the Facebook account with a user record in your database, - // and return that user instead. - return done(null, profile); - //}); + done(null, profile); } )); diff --git a/src/utils.js b/src/utils.js index b5f51ccb91..ba8dd47f95 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,6 +2,7 @@ var nodemailer = require('nodemailer'); var nconf = require('nconf'); var crypto = require('crypto'); var path = require("path"); +var request = require('request'); module.exports.ga = undefined; // set Google Analytics on nconf init @@ -21,6 +22,46 @@ module.exports.sendEmail = function(mailData) { }); } +function getMailingInfo(user) { + var email, name; + if(user.auth.local && user.auth.local.email){ + email = user.auth.local.email; + name = user.profile.name || user.auth.local.username; + }else if(user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value){ + email = user.auth.facebook.emails[0].value; + name = user.auth.facebook.displayName || user.auth.facebook.username; + } + return {email: email, name: name}; +} + +module.exports.txnEmail = function(mailingInfo, emailType, variables){ + if (mailingInfo._id) mailingInfo = getMailingInfo(mailingInfo); + if (!mailingInfo.email) return; + request({ + url: nconf.get('EMAIL_SERVER:url') + '/job', + method: 'POST', + auth: { + user: nconf.get('EMAIL_SERVER:authUser'), + pass: nconf.get('EMAIL_SERVER:authPassword') + }, + json: { + type: 'email', + data: { + emailType: emailType, + to: { + name: mailingInfo.name, + email: mailingInfo.email + }, + variables: variables + }, + options: { + attemps: 5, + backoff: {delay: 10*60*1000, type: 'fixed'} + } + } + }); +} + // Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ // Note: would use [password-hash](https://github.com/davidwood/node-password-hash), but we need to run // model.query().equals(), so it's a PITA to work in their verify() function @@ -49,4 +90,19 @@ module.exports.setupConfig = function(){ require('newrelic'); module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); -}; \ No newline at end of file +}; + +var algorithm = 'aes-256-ctr'; +module.exports.encrypt = function(text){ + var cipher = crypto.createCipher(algorithm,nconf.get('SESSION_SECRET')) + var crypted = cipher.update(text,'utf8','hex') + crypted += cipher.final('hex'); + return crypted; +} + +module.exports.decrypt = function(text){ + var decipher = crypto.createDecipher(algorithm,nconf.get('SESSION_SECRET')) + var dec = decipher.update(text,'hex','utf8') + dec += decipher.final('utf8'); + return dec; +} \ No newline at end of file diff --git a/test/api.mocha.coffee b/test/api.mocha.coffee index a1e75ec11e..28b2a97c3d 100644 --- a/test/api.mocha.coffee +++ b/test/api.mocha.coffee @@ -606,7 +606,7 @@ describe "API", -> user = _user done() - it.skip "Handles unsubscription", (done) -> + it "Handles unsubscription", (done) -> cron = -> user.lastCron = moment().subtract("d", 1) user.fns.cron() diff --git a/views/avatar-static.jade b/views/avatar-static.jade new file mode 100644 index 0000000000..9223081dba --- /dev/null +++ b/views/avatar-static.jade @@ -0,0 +1,28 @@ +doctype html +html(ng-app="habitrpg") + head + title=title + link(rel='shortcut icon', href='#{env.getBuildUrl("favicon.ico")}?v=3') + + meta(charset='utf-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='apple-mobile-web-app-capable', content='yes') + + script(type='text/javascript'). + window.env = !{JSON.stringify(env)}; + + != env.getManifestFiles("app") + + script(type='text/javascript'). + window.habitrpg + .controller('StaticAvatarCtrl', ['$scope', function($scope){ + $scope.profile = window.env.user; + }]) + + //webfonts + link(href='//fonts.googleapis.com/css?family=Lato:300,400,700,400italic,700italic', rel='stylesheet', type='text/css') + + body(ng-cloak) + include ./shared/header/avatar + div(ng-controller='StaticAvatarCtrl') + +herobox({main:true}) diff --git a/views/index.jade b/views/index.jade index 802e3873eb..bd9f42f9f5 100644 --- a/views/index.jade +++ b/views/index.jade @@ -10,6 +10,9 @@ html(ng-app="habitrpg", ng-controller="RootCtrl", ng-class='{"applying-action":a meta(name='viewport', content='width=device-width, initial-scale=1.0') meta(name='apple-mobile-web-app-capable', content='yes') + //FIXME for some reason this won't load when in footerCtrl.js#deferredScripts() + script(type="text/javascript", src="//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4", async="async") + script(type='text/javascript'). window.env = !{JSON.stringify(env)}; diff --git a/views/options/inventory/inventory.jade b/views/options/inventory/inventory.jade index 9f5f912e22..e4e8574b89 100644 --- a/views/options/inventory/inventory.jade +++ b/views/options/inventory/inventory.jade @@ -11,7 +11,7 @@ script(type='text/ng-template', id='partials/options.inventory.equipment.html') h3.equipment-title.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('costumeText'))=env.t('costume') .checkbox.equipment-title label - input(type="checkbox", ng-model="user.preferences.costume", ng-click='set({"preferences.costume":!user.preferences.costume})') + input(type="checkbox", ng-model="user.preferences.costume", ng-change='set({"preferences.costume":user.preferences.costume ? true : false})') |  =env.t('useCostume') li.customize-menu(ng-if='user.preferences.costume') @@ -106,7 +106,7 @@ script(type='text/ng-template', id='partials/options.inventory.drops.html') | {{::egg.value}} span.Pet_Currency_Gem1x.inline-gems //- buyable quest eggs - each egg,quest in {gryphon:'Gryphon',hedgehog:'Hedgehog',ghost_stag:'Deer',rat:'Rat',octopus:'Octopus',dilatory_derby:'Seahorse',harpy:'Parrot',rooster:'Rooster',spider:'Spider'} + each egg,quest in {gryphon:'Gryphon',hedgehog:'Hedgehog',ghost_stag:'Deer',rat:'Rat',octopus:'Octopus',dilatory_derby:'Seahorse',harpy:'Parrot',rooster:'Rooster',spider:'Spider',owl:'Owl'} div(ng-show='user.achievements.quests.#{quest} > 1') button.customize-option(popover='{{::Content.eggs.#{egg}.notes()}}', popover-title!=env.t("egg", {eggType: "{{::Content.eggs.#{egg}.text()}}"}), popover-trigger='mouseenter', popover-placement='left', ng-click='purchase("eggs", Content.eggs.#{egg})', class='Pet_Egg_#{egg}') p diff --git a/views/options/inventory/stable.jade b/views/options/inventory/stable.jade index 0bfeaa09fe..6fa5b75aa8 100644 --- a/views/options/inventory/stable.jade +++ b/views/options/inventory/stable.jade @@ -54,8 +54,6 @@ script(type='text/ng-template', id='partials/options.inventory.mounts.html') each t,k in env.Content.specialMounts - var animal = k.split('-')[0], color = k.split('-')[1] button(ng-if='user.items.mounts["#{animal}-#{color}"]', class="pet-button Mount_Head_#{animal}-#{color}", ng-class='{active: user.items.currentMount == "#{animal}-#{color}"}', ng-click='chooseMount("#{animal}", "#{color}")', popover=env.t(t), popover-trigger='mouseenter', popover-placement='bottom') - button(class="pet-button pet-not-owned", ng-hide='user.items.mounts["#{mount}"]') - .PixelPaw script(type='text/ng-template', id='partials/options.inventory.pets.html') .container-fluid @@ -99,7 +97,7 @@ script(type='text/ng-template', id='partials/options.inventory.pets.html') li.customize-menu menu.pets-menu(label=env.t('food')) div(ng-repeat='(food,points) in ownedItems(user.items.food)') - button.customize-option(popover-append-to-body='true', popover='{{:: Content.food[food].notes()}}', popover-title='{{:: Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='left', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') + button.customize-option(popover-append-to-body='true', popover='{{:: Content.food[food].notes()}}', popover-title='{{:: Content.food[food].text()}}', popover-trigger='mouseenter', popover-placement='top', ng-click='chooseFood(food)', class='Pet_Food_{{::food}}') .badge.badge-info.stack-count {{points}} // Remove this once we have images in p {{:: Content.food[food].text()}} diff --git a/views/options/profile.jade b/views/options/profile.jade index 5920b9df78..ec620e135a 100644 --- a/views/options/profile.jade +++ b/views/options/profile.jade @@ -4,19 +4,21 @@ mixin gemCost(cost) = ' ' + env.t('locked') block +-var showPath = function(path, items, joiner) { return path+'["'+items.join('"] '+joiner+' '+path+'["')+'"]'; } +-var unlockPath = function(path, items) { return 'unlock("'+path+'.'+items.join(','+path+'.')+'")'; } // Make it a mixin so we can call it from mobile mixin customizeProfile(mobile) mixin buyPref(path,colors,title,status) - li.customize-menu(ng-if='#{status=="disabled" ? "user.purchased."+path+"."+colors.join(" || user.purchased."+path+".") : true}', class=~["limited","seasonal"].indexOf(status) ? "well limited-edition" : "") + li.customize-menu(ng-if='#{status=="disabled" ? showPath("user.purchased."+path, colors, "||") : true}', class=~["limited","seasonal"].indexOf(status) ? "well limited-edition" : "") 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"} || user.purchased.#{path}.#{colors.join(" && user.purchased."+path+".")}', ng-click='unlock("#{path}.#{colors.join(","+path+".")}")')!= env.t('unlockSet',{cost:5}) + ' ' + button.btn.btn-xs(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "||")}', 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}")') + 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}")') div(class=mobile ? 'padding' : 'container-fluid row') .col-md-4 @@ -35,9 +37,10 @@ mixin customizeProfile(mobile) button.customize-option(class='{{user.preferences.size}}_shirt_'+shirt, type='button', ng-click='set({"preferences.shirt":"'+shirt+'"})') 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="user.purchased.shirt.convict && user.purchased.shirt.cross && user.purchased.shirt.fire && user.purchased.shirt.horizon && user.purchased.shirt.ocean && user.purchased.shirt.purple && user.purchased.shirt.rainbow && user.purchased.shirt.redblue && user.purchased.shirt.thunder && user.purchased.shirt.tropical && user.purchased.shirt.zombie", ng-click='unlock("shirt.convict,shirt.cross,shirt.fire,shirt.horizon,shirt.ocean,shirt.purple,shirt.rainbow,shirt.redblue,shirt.thunder,shirt.tropical,shirt.zombie")')!= env.t('unlockSet',{cost:5}) + ' ' - each shirt in ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie'] + button.btn.btn-xs(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}', 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+'")') @@ -76,10 +79,11 @@ 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='user.purchased.hair.base.2 && user.purchased.hair.base.4 && user.purchased.hair.base.5 && user.purchased.hair.base.6 && user.purchased.hair.base.7 && user.purchased.hair.base.8', ng-click='unlock("hair.base.2,hair.base.4,hair.base.5,hair.base.6,hair.base.7,hair.base.8")')!= env.t('unlockSet',{cost:5}) + ' ' - each num in [2,4,5,6,7,8] - 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}")') + button.btn.btn-xs(ng-hide='#{showPath("user.purchased.hair", colors, "&&")}', 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}")') // Flower li.customize-menu @@ -90,21 +94,20 @@ mixin customizeProfile(mobile) 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}) + ' ' + 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}) + ' ' // Beard menu(label=env.t('beard')) button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.beard":0})') each num in [1,2,3] - button(class='hair_beard_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.beard.#{num}}', ng-click='unlock("hair.beard.#{num}")') + button(class='hair_beard_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.beard["#{num}"]}', ng-click='unlock("hair.beard.#{num}")') // Mustache menu(label=env.t('mustache')) button(class='head_0 customize-option', type='button', ng-click='set({"preferences.hair.mustache":0})') each num in [1,2] - button(class='hair_mustache_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.mustache.#{num}}', ng-click='unlock("hair.mustache.#{num}")') + button(class='hair_mustache_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.mustache["#{num}"]}', ng-click='unlock("hair.mustache.#{num}")') .col-md-4 h3(class=mobile?'item item-divider':'')=env.t('bodySkin') diff --git a/views/options/settings.jade b/views/options/settings.jade index f8f186c1fd..99c94f4dc2 100644 --- a/views/options/settings.jade +++ b/views/options/settings.jade @@ -63,6 +63,7 @@ script(type='text/ng-template', id='partials/options.settings.settings.html') button.btn.btn-default(ng-click='showTour()', popover-placement='right', popover-trigger='mouseenter', popover=env.t('restartTour'))= env.t('showTour') button.btn.btn-default(ng-click='showBailey()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('showBaileyPop'))= env.t('showBailey') button.btn.btn-default(ng-click='openRestoreModal()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('fixValPop'))= env.t('fixVal') + button.btn.btn-default(ng-click="openModal('invite-friends', {controller:'GroupsCtrl'})") Invite Friends button.btn.btn-default(ng-if='user.preferences.disableClasses==true', ng-click='user.ops.changeClass({})', popover-trigger='mouseenter', popover-placement='right', popover=env.t('enableClassPop'))= env.t('enableClass') button.btn.btn-default(ng-if='!user.preferences.disableClasses && user.flags.classSelected', ng-click='showClassesTour()', popover-trigger='mouseenter', popover-placement='right', popover=env.t('classTourPop'))= env.t('showClass') @@ -150,6 +151,33 @@ script(type='text/ng-template', id='partials/options.settings.api.html') h6=env.t('qrCode') img(src='https://chart.googleapis.com/chart?cht=qr&chs=200x200&chl=%7B%22address%22%3A%22https%3A%2F%2Fhabitrpg.com%22%2C%22user%22%3A%22{{user.id}}%22%2C%22key%22%3A%22{{user.apiToken}}%22%7D&choe=UTF-8&chld=L', alt='qrcode') + hr + + h2 Webhooks + table.table.table-striped + thead(ng-if='hasWebhooks') + tr + th Enabled + th Webhook URL + th + tbody + tr(ng-repeat="webhook in user.preferences.webhooks | toArray:true | orderBy:'sort'") + td + input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook.$key,webhook)') + td + input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook.$key,webhook)'}") + td + span.pull-left(ng-show='webhook._editing') * + a.checklist-icons(ng-click='deleteWebhook(webhook.$key)') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + tr + td(colspan=2) + form.form-horizontal(ng-submit='addWebhook(_newWebhook.url)') + .form-group.col-sm-10 + input.form-control(type='url', ng-model='_newWebhook.url', placeholder='Webhook URL') + .col-sm-2 + button.btn.btn-sm.btn-primary(type='submit') Add + script(id='partials/options.settings.export.html', type="text/ng-template") .container-fluid .row @@ -194,25 +222,21 @@ script(id='partials/feature-matrix-check.html',type='text/ng-template') label script(id='partials/options.settings.subscription.html',type='text/ng-template') - .well - h2=env.t('individualSub') - div(ng-if='!user.purchased.plan.customerId') - div(ng-include="'partials/options.settings.subscription.perks.html'") - p - small.muted Payment Methods: - .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='user.purchased.plan.customerId') - p.alert.alert-warning(ng-if='user.purchased.plan.dateTerminated') - i.glyphicon.glyphicon-time - |   - =env.t('subCanceled') - | {{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}} - p.lead - =env.t('subscribed') - |   - span.glyphicon.glyphicon-ok - div(ng-include="'partials/options.settings.subscription.perks.html'") - .btn.btn-primary(ng-if=':: !user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='showStripeEdit()') Update Card - .btn.btn-sm.btn-danger(ng-if=':: !user.purchased.plan.dateTerminated', ng-click='cancelSubscription()')=env.t('cancelSub') + .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') + div(ng-include="'partials/options.settings.subscription.perks.html'") + + 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') diff --git a/views/options/social/chat-box.jade b/views/options/social/chat-box.jade index 7953e7b1b6..3b55ddb8ec 100644 --- a/views/options/social/chat-box.jade +++ b/views/options/social/chat-box.jade @@ -1,18 +1,16 @@ -//div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAccepted') -// p If you would like to post messages in the Tavern or any party or guild chat, please first read our -// |  -// a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') -// | and then click the button below to indicate that you accept them. -// .chat-controls -// div -// button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') -// .chat-buttons -// button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) -// span.glyphicon.glyphicon-refresh +div.chat-form.guidelines-not-accepted(ng-if='!user.flags.communityGuidelinesAccepted') + p If you would like to post messages in the Tavern or any party or guild chat, please first read our + |  + a(target='_blank', href='/static/community-guidelines')=env.t('communityGuidelines') + | and then click the button below to indicate that you accept them. + .chat-controls + div + button.btn.btn-warning(ng-click='acceptCommunityGuidelines()')=env.t('iAcceptCommunityGuidelines') + .chat-buttons + button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) + span.glyphicon.glyphicon-refresh -//form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') -//////////// When we want to block the ability to chat until the Community Guidelines have been accepted, delete the one line immediately below this comment and un-comment all lines above. -form.chat-form(ng-submit='postChat(group,message.content)') +form.chat-form(ng-if='user.flags.communityGuidelinesAccepted' ng-submit='postChat(group,message.content)') div(ng-controller='AutocompleteCtrl') textarea.form-control(rows=4, ui-keypress='{13:"postChat(group,message.content)"}', ng-model='message.content', updateinterval='250', flag='@', at-user, auto-complete) span.user-list(ng-show='!isAtListHidden') @@ -24,5 +22,4 @@ form.chat-form(ng-submit='postChat(group,message.content)') include ../../shared/formatting-help .chat-buttons input(type='submit', value=env.t('sendChat'), ng-class='{disabled: _sending == true}') - button(type="button", ng-click='sync(group)', tooltip=env.t('toolTipMsg')) - span.glyphicon.glyphicon-refresh + button(type="button", ng-click='sync(group)')=env.t('toolTipMsg') diff --git a/views/options/social/chat-message.jade b/views/options/social/chat-message.jade index d7507908a5..27ecf66f70 100644 --- a/views/options/social/chat-message.jade +++ b/views/options/social/chat-message.jade @@ -1,19 +1,32 @@ -li.chat-message(ng-repeat='message in group.chat track by message.id', ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}') - .scrollable-message - span(ng-if='::message.user') - a.label.label-default.chat-message.hidden-label - span {{::message.user}}  - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') - // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text - markdown(ng-model='::message.text') - | - - span.muted.time(from-now='::message.timestamp') - span - a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} - a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group,message)') +1 - |   - a(ng-if=':: user.contributor.admin || message.uuid == user.id', ng-click='deleteChatMessage(group, message)') - span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) - a.label.label-default.chat-message(ng-if=':: message.user', class='float-label', ng-class='userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)') - span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}}  - span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') +mixin chatMessages(inbox) + ul.list-unstyled.tavern-chat + - var ngRepeat = inbox ? 'message in user.inbox.messages | toArray:true | orderBy:"sort":true' : 'message in group.chat track by message.id' + li.chat-message(ng-repeat=ngRepeat, ng-class=':: {highlight: isUserMentioned(user,message) || message.uuid=="system", "own-message": user._id == message.uuid}', style='{{::message.sent ? "opacity:0.5" : ""}}') + .scrollable-message + span(ng-if='::message.user') + a.label.label-default.chat-message.hidden-label + span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent') + span {{::message.user}}  + span(ng-class='userAdminGlyphiconStyleFromLevel(message.contributor.level)') + // this invisible username label is here to push the message text far enough right that the visible label can be floated to this point without covering up any of the text + markdown(ng-model='::message.text') + | - + span.muted.time(from-now='::message.timestamp') + unless inbox + span + a.label.label-default(ng-show='countExists(message.likes)', ng-class='{"label-success":message.likes[user._id]}', ng-click='likeChatMessage(group,message)') +{{countExists(message.likes)}} + a.chat-plus-one.muted(ng-show='!countExists(message.likes)', ng-click='likeChatMessage(group, message)') +1 + |     + a(ng-click="quickReply(message.uuid)") + span.glyphicon.glyphicon-envelope(tooltip=env.t('sendPM')) + if inbox + a(ng-click="quickReply(message.uuid)") + span.glyphicon.glyphicon-share-alt(tooltip=env.t('pm-reply')) + |         + a(ng-click='#{inbox? "user.ops.deletePM({params:{id:message.$key}})" : "deleteChatMessage(group, message)"}', ng-if='#{inbox ? "true" : ":: user.contributor.admin || message.uuid == user.id"}') + span.glyphicon.glyphicon-trash(tooltip=env.t('delete')) + span.float-label + 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') + span(tooltip='{{::contribText(message.contributor, message.backer)}}') {{::message.user}}  + span(ng-class='::userAdminGlyphiconStyleFromLevel(message.contributor.level)') diff --git a/views/options/social/group.jade b/views/options/social/group.jade index df056b816b..ec36aea8b4 100644 --- a/views/options/social/group.jade +++ b/views/options/social/group.jade @@ -48,9 +48,11 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter // ------ Members ------- .panel.panel-default .panel-heading - h3.panel-title=env.t('members') + h3.panel-title + =env.t('members') + button.pull-right.btn.btn-primary(ng-click="openModal('invite-friends', {controller:'GroupsCtrl'})", ng-if='::group.type=="party"') Invite Friends .panel-body.modal-fixed-height - div(ng-if='group.type=="party"') + div(ng-if='::group.type=="party"') =env.t('partyList') br select#partyOrder( @@ -86,7 +88,7 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter a.media-body span(ng-click='clickMember(invite._id, true)') | {{invite.profile.name}} - .panel-footer + .panel-footer(ng-if='::group.type!="party"') form.form-inline(ng-submit='invite(group)') //.alert.alert-danger(ng-show='_groupError') {{_groupError}} .form-group @@ -112,5 +114,4 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter h3=env.t('chat') include ./chat-box - ul.list-unstyled.tavern-chat - include ./chat-message + +chatMessages() diff --git a/views/options/social/index.jade b/views/options/social/index.jade index b632b1c6d9..b0fb4053c8 100644 --- a/views/options/social/index.jade +++ b/views/options/social/index.jade @@ -1,9 +1,22 @@ // FIXME note, due to https://github.com/angular-ui/bootstrap/issues/783 we can't use nested angular-bootstrap tabs // Subscribe to that ticket & change this when they fix -include ./challenges.jade -include ./hall.jade -include ./boss.jade +include ./challenges +include ./hall +include ./boss +include ./chat-message + +script(type='text/ng-template', id='partials/options.social.inbox.html') + .container-fluid + .row + .col-md-12 + +chatMessages('inbox') + .form-inline + a.btn.btn-xs.btn-danger(popover=env.t('clearAllPopover'), popover-trigger='mouseenter', ng-click='user.ops.clearPMs({})', popover-placement='right')=env.t('clearAll') + .checkbox + label + input(type='checkbox', ng-model='user.inbox.optOut', ng-change='set({"inbox.optOut": user.inbox.optOut?true: false})') + span.hint(popover-trigger='mouseenter', popover-placement='right', popover=env.t('optOutPopover'))=env.t('optOut') script(type='text/ng-template', id='partials/options.social.tavern.html') include ./tavern @@ -78,6 +91,10 @@ script(type='text/ng-template', id='partials/options.social.guilds.html') script(type='text/ng-template', id='partials/options.social.html') ul.options-menu + li(ng-class="{ active: $state.includes('options.social.inbox') }") + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li(ng-class="{ active: $state.includes('options.social.tavern') }") a(ui-sref='options.social.tavern') =env.t('tavern') diff --git a/views/options/social/tavern.jade b/views/options/social/tavern.jade index 91b5d8caa3..753dd02c45 100644 --- a/views/options/social/tavern.jade +++ b/views/options/social/tavern.jade @@ -149,10 +149,10 @@ != ' ' + env.t('tavernAlert1') + ' ' + env.t('tavernAlert2') + '.
' + env.t('moderatorIntro1') span(ng-repeat='mod in env.mods') |    - span(ng-if='mod.contributor.admin',popover=env.t('gamemaster'),popover-trigger='mouseenter',popover-placement='right') - a.label.label-default(ng-class='userLevelStyle(mod)', ng-click='clickMember(mod._id, true)') - {{mod.profile.name}}  - span(ng-class='userAdminGlyphiconStyle(mod)') + span(ng-if='::mod.contributor.admin',popover=env.t('gamemaster'),popover-trigger='mouseenter',popover-placement='right') + a.label.label-default(ng-class='::userLevelStyle(mod)', ng-click='clickMember(mod._id, true)') + {{::mod.profile.name}}  + span(ng-class='::userAdminGlyphiconStyle(mod)') p =env.t('communityGuidelinesRead1') |   @@ -160,5 +160,4 @@ |   =env.t('communityGuidelinesRead2') - ul.list-unstyled.tavern-chat - include ./chat-message + +chatMessages() diff --git a/views/shared/header/avatar.jade b/views/shared/header/avatar.jade index 415abe6ede..41d70ba1ea 100644 --- a/views/shared/header/avatar.jade +++ b/views/shared/header/avatar.jade @@ -7,6 +7,8 @@ mixin avatar(opts) .character-sprites + .addthis_native_toolbox(ng-if='profile._id==user._id', data-url="#{env.BASE_URL}/static/front/#?memberId={{profile._id}}", data-title="Check out my HabitRPG progress!") + // Mount Body if !opts.minimal span(ng-if='profile.items.currentMount', class='Mount_Body_{{profile.items.currentMount}}') diff --git a/views/shared/header/menu.jade b/views/shared/header/menu.jade index b768c3935e..5b63d705cb 100644 --- a/views/shared/header/menu.jade +++ b/views/shared/header/menu.jade @@ -24,6 +24,10 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li a(ui-sref='options.profile.profile')=env.t('profile') ul.toolbar-submenu + li + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li a(ui-sref='options.social.tavern')=env.t('tavern') li @@ -43,6 +47,11 @@ 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') + ul.toolbar-submenu + li + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') + li + a(ui-sref='options.settings.export')=env.t('exportData') ul.toolbar-submenu li a(target="_blank" href='http://habitrpg.wikia.com/wiki/FAQ')=env.t('FAQ') @@ -83,10 +92,15 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') li.toolbar-button-dropdown a(ui-sref='options.social.tavern') span=env.t('social') + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} a(ng-click='expandMenu("social")', ng-class='{active: _expandedMenu == "social"}') span ☰ div(ng-if='_expandedMenu == "social"') ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(ui-sref='options.social.inbox') + | Inbox  + span.badge.badge-danger(ng-if='user.inbox.newMessages') {{user.inbox.newMessages}} li a(ui-sref='options.social.tavern')=env.t('tavern') li @@ -112,6 +126,17 @@ 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.toolbar-button-dropdown + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}') + span=env.t('data') + a(ng-click='expandMenu("data")', ng-class='{active: _expandedMenu == "data"}') + span ☰ + div(ng-if='_expandedMenu == "data"') + ul.toolbar-submenu(ng-click='expandMenu(null)') + li + a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool') + li + a(ui-sref='options.settings.export')=env.t('exportData') li.toolbar-button-dropdown a(target="_blank" href='http://habitrpg.wikia.com/wiki/') span=env.t('help') @@ -131,6 +156,8 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}') a(target="_blank" href='http://habitrpg.wikia.com/wiki/Contributing_to_HabitRPG')=env.t('contributeToHRPG') li a(target="_blank" href='http://habitrpg.wikia.com/wiki/')=env.t('overview') + li(ng-controller='SettingsCtrl') + a(ng-click='showTour()', popover-placement='right', popover-trigger='mouseenter', popover=env.t('restartTour'))= env.t('showTour') ul.toolbar-subscribe(ng-if='!user.purchased.plan.customerId') li.toolbar-subscribe-button button(ui-sref='options.settings.subscription',popover-trigger='mouseenter',popover-placement='bottom',popover-title=env.t('subscriptions'),popover=env.t('subDescription'),popover-append-to-body='true')=env.t('subscribe') diff --git a/views/shared/modals/index.jade b/views/shared/modals/index.jade index 8d5b74e8ab..f8f5433996 100644 --- a/views/shared/modals/index.jade +++ b/views/shared/modals/index.jade @@ -10,3 +10,4 @@ include ./classes include ./quests include ./rebirth include ./limited +include ./invite-friends diff --git a/views/shared/modals/invite-friends.jade b/views/shared/modals/invite-friends.jade new file mode 100644 index 0000000000..02e4ce0c8d --- /dev/null +++ b/views/shared/modals/invite-friends.jade @@ -0,0 +1,48 @@ +script(type='text/ng-template', id='modals/invite-friends.html') + .modal-header + h4 Invite Friends + .modal-body + p.alert.alert-info Invite friends by User ID here. + + form.form-inline(ng-submit='invite(party)') + //-.alert.alert-danger(ng-show='_groupError') {{_groupError}} + .form-group + input.form-control(type='text', placeholder=env.t('userId'), ng-model='party.invitee') + |  + button.btn.btn-primary(type='submit') Invite Existing User + + hr + + p.alert.alert-info Invite friends by email. If they join via your email, they'll automatically be invited to your party. + + form.form-horizontal(ng-submit='inviteEmails(inviter, emails)') + table.table.table-striped + thead + tr + th Name + th Email + tbody + tr(ng-repeat='email in emails') + td + input.form-control(type='text', ng-model='email.name') + td + input.form-control(type='email', ng-model='email.email') + tr + td(colspan=2) + a.btn.btn-xs.pull-right(ng-click='emails = emails.concat([{name:"",email:""}])') + i.glyphicon.glyphicon-plus + tr + td.form-group(colspan=2) + label.col-sm-1.control-label By: + .col-sm-7 + input.form-control(type='text', ng-model='inviter') + .col-sm-4 + button.btn.btn-primary(type='submit') Invite New User(s) + //- + hr + p.alert.alert-info Or share this link (copy/paste): + input.form-control(type='text', ng-value='inviteLink({id: party._id, inviter: user._id, name: party.name})') + + .modal-footer + button.btn.btn-default(ng-click='$close()') Close + diff --git a/views/shared/modals/members.jade b/views/shared/modals/members.jade index eb7e843bb9..55181df160 100644 --- a/views/shared/modals/members.jade +++ b/views/shared/modals/members.jade @@ -1,32 +1,50 @@ -script(type='text/ng-template', id='modals/member.html') - .modal-header(bindonce='profile') +script(type='text/ng-template', id='modals/member.html') + .modal-header h4 - span {{profile.profile.name}} - span(ng-if='profile.contributor.level') - {{contribText(profile.contributor, profile.backer)}} - .modal-body(bindonce='profile') + span {{::profile.profile.name}} + span(ng-if='profile.contributor.level') - {{::contribText(profile.contributor, profile.backer)}} + .modal-body .container-fluid .row .col-md-6 - img(ng-show='profile.profile.imageUrl', ng-src='{{profile.profile.imageUrl}}') - markdown(ng-show='profile.profile.blurb', ng-model='profile.profile.blurb') - ul.muted.list-unstyled(ng-if='profile.auth.timestamps') + img(ng-show='::profile.profile.imageUrl', ng-src='{{::profile.profile.imageUrl}}') + markdown(ng-show='::profile.profile.blurb', ng-model='::profile.profile.blurb') + ul.muted.list-unstyled(ng-if='::profile.auth.timestamps') li {{profile._id}} - li(ng-show='profile.auth.timestamps.created') + li(ng-show='::profile.auth.timestamps.created') |  =env.t('memberSince') |  - | {{timestamp(profile.auth.timestamps.created)}} - - li(ng-show='profile.auth.timestamps.loggedin') + | {{::timestamp(profile.auth.timestamps.created)}} - + li(ng-show='::profile.auth.timestamps.loggedin') |  =env.t('lastLoggedIn') |  - | {{timestamp(profile.auth.timestamps.loggedin)}} - + | {{::timestamp(profile.auth.timestamps.loggedin)}} - h3=env.t('stats') - .label.label-info {{ {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[profile.stats.class] }} + .label.label-info {{:: {warrior:env.t("warrior"), wizard:env.t("mage"), rogue:env.t("rogue"), healer:env.t("healer")}[profile.stats.class] }} include ../profiles/stats .col-md-6 - +herobox() - h3=env.t('achievements') - include ../profiles/achievements + .row + +herobox() + .row + h3=env.t('achievements') + include ../profiles/achievements .modal-footer - button.btn.btn-default(ng-click='$close()')=env.t('ok') + .btn-group.pull-left(ng-if='::user') + button.btn.btn-md.btn-default(ng-show='user.inbox.blocks | contains:profile._id', tooltip=env.t('unblock'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + span.glyphicon.glyphicon-plus + button.btn.btn-md.btn-default(ng-hide='user.inbox.blocks | contains:profile._id', tooltip=env.t('block'), ng-click="user.ops.blockUser({params:{uuid:profile._id}})", tooltip-placement='right') + 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-default(ng-click='$close()')=env.t('close') + +script(type='text/ng-template', id='modals/private-message.html') + .modal-header + h4=env.t('pmHeading', {name: "{{profile.profile.name}}"}) + .modal-body + textarea.form-control(type='text',ng-model='_message') + .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') diff --git a/views/shared/new-stuff.jade b/views/shared/new-stuff.jade index 84e4b24079..f1b254710b 100644 --- a/views/shared/new-stuff.jade +++ b/views/shared/new-stuff.jade @@ -8,14 +8,113 @@ table h3.popover-title a(target='_blank', href='https://twitter.com/Mihakuu') Bailey .popover-content + h5 HAPPY THANKSGIVING! table.table.table-striped tr td - h5 November Mystery Item Set - .pull-right.inventory_present - p Cool! What could it be? All Habiticans who are subscribed during the month of November will receive the November Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 - p.small.muted 12/01/2014 + h5 Happy Thanksgiving! + p It's Thanksgiving in Habitica! On this day Habiticans celebrate by spending time with loved ones, giving thanks, and riding their glorious turkeys into the magnificent sunset. Some of the NPCs are celebrating the occasion! + p.small.muted by Lemoness + tr + td + h5 Turkey Pet and Mount! + p Those of you who weren't around last Thanksgiving have received an adorable Turkey Pet, and those of you who got a Turkey Pet last year have received a handsome Turkey Mount! Thank you for using HabitRPG - we really love you guys <3 + p.small.muted by Lemoness + p.small.muted 11/26/2014 +h5 11/25/2014 +table.table.table-striped + tr + td + h5 November Item Set Revealed + p The November Subscriber Item has been revealed: the Feast and Fun Set! All November subscribers will receive the Pitchfork of Feasting and the Steel Helm of Sporting. You still have five days to subscribe and receive the item set! Thank you so much for your support - we really do rely on you to keep HabitRPG free to use and running smoothly. + p.small.muted by Lemoness + tr + td + h5 Private Messaging Version 1.0 + p We're excited to announce a new feature: Private Messaging! Now you can send someone a PM by clicking the envelope icon in the bottom-left of their profile window . You can check your messages under Social > Inbox! This is a very rudimentary feature so far, only containing the ability to send messages, block people, and opt out. To read about some of the planned features for the future and make suggestions, check out this Trello card! + p.small.muted by Lefnire + +h5 11/18/2014 +table.table.table-striped + tr + td + h5 New Pet Quest: The Night-Owl! + p Habiticans are in the dark when a giant Night-Owl blots out the Tavern light! Can you drive it away in time to finish your all-nighter? If so, you may find some cute pet owls in the morning... + p.small.muted by Twitching, Lemoness, and Arcosine + +h5 11/13/2014 - Share Avatar To Social Media, Email Invites, First Mini Quest, And Data Tab +table.table.table-striped + tr + td + h5 Share Avatar To Social Media + p You can now automatically share your avatar and public profile to social media! Just hover over the picture and click the "Share" button in the right-hand corner. Show off your outfit, your achievements, and your profile picture! Note that your tasks, as always, remain 100% private. + p.small.muted by Lefnire + tr + td + h5 Invite Friends To Party Via Email + p Do you want to invite friends to join your party without inputting their User ID? Now you can send them an email directly from the party page - even if they don't have an account yet! + p.small.muted by Lefnire + tr + td + h5 Mini Quest: The Basi-List! + p Now when someone accepts your party invitation and joins your party, you will be given a Mini Quest: The Basi-List! Battle the Basi-List with your friends for an XP and GP reward. + p.small.muted by Arcosine and Redphoenix + tr + td + h5 Data Tab + p Now you can access the Data Display Tool and Export Data from the toolbar! + p.small.muted by ShilohT + +h5 11/12/2014 +table.table.table-striped + tr + td + h5 New Equipment Quest Line: The Golden Knight! + p The Golden Knight believes that she is the perfect Habitican, and that anyone who slips up in their quest for self-improvement is a lazy failure. Can you talk some sense into her - or will it come to blows? If you complete the entire quest line, you'll be rewarded with a legendary weapon... + p The first scroll in this quest line, "A Stern Talking-to," drops automatically at Level 40! If you're already over Level 40, you will automatically be awarded this quest - just check off a task and then check your inventory. + +h5 11/09/2014 - Facebook Login Fixed For Mobile And Community Guidelines To Chat +table.table.table-striped + tr + td + h5 Facebook Login Fixed For Mobile! + p Great news! If you use Facebook to log in to the mobile app, we've released an update so you no longer have to type in your UUID/API manually, misspelling things on your tiny keyboard and bemoaning your fate. Thank goodness! The Android update is out now, and the iOS update has been submitted and should be out soon. + tr + td + h5 Community Guidelines To Chat + p Before you can use any of the public chat features, you now have to agree to our Community Guidelines. We know they're long, but they're important, so please do read them if you haven't already. Plus, we worked hard to make them entertaining, and they were illustrated by many of our excellent artisans! + +h5 11/06/2014 +table.table.table-striped + tr + td + h5 Bailey: Costume Challenge Badges Awarded! + p The HabitRPG Costume Challenge Badges have been awarded! Thanks for your patience while we went through all the entries individually. You can see some of the entries on the HabitRPG blog already, and more will be added every week. + p IMPORTANT: some of the links that people provided did not work. If you entered the Challenge but even after refreshing the page you still don't have your badge, email leslie@habitrpg.com with the link to your costume and your avatar. (The costume and avatar must have been posted prior to November 1st to count.) + p Thanks to all our amazing participants! + +h5 11/05/2014- November Backgrounds And Beeminder Integration +table.table.table-striped + tr + td + h5 November Backgrounds + p There are three new avatar backgrounds in the Background Shop! Now your avatar can enjoy a Harvest Feast, admire a Sunset Meadow, or gaze at the Starry Skies! + p.small.muted by Kiwibot, Holsety1, and Draayder + tr + td + h5 Beeminder Integration + p We've integrated with Beeminder! Now you can beemind your To-Dos automatically :) Check it out! + p If you've never heard of Beeminder or want to learn more about what we've integrated so far, check out our blog post about it. Enjoy! + p.small.muted by Alys and Alice Monday + +h5 11/01/2014 +table.table.table-striped + tr + td + h5 November Mystery Item Set + .pull-right.inventory_present + p Cool! What could it be? All Habiticans who are subscribed during the month of November will receive the November Mystery Item Set! It will be revealed on the 25th, so keep your eyes peeled. Thanks for supporting the site <3 h5 10/31/2014 - Monster Npcs, Last Day For Fall Festival Items, Last Day Of Community Costume Challenge, Last Day For Winged Goblin Item Set table.table.table-striped diff --git a/views/shared/tasks/lists.jade b/views/shared/tasks/lists.jade index fa8adb2e1d..f5b04e38a9 100644 --- a/views/shared/tasks/lists.jade +++ b/views/shared/tasks/lists.jade @@ -4,11 +4,11 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") .tasks-lists.container-fluid .row - .col-md-3.col-sm-6(bindonce='lists', ng-repeat='list in lists', bo-class='{"rewards-module": list.type==="reward"}') + .col-md-3.col-sm-6(bindonce='lists', ng-repeat='list in lists', ng-class='::{"rewards-module": list.type==="reward"}') .task-column(class='{{list.type}}s') // Todos export/graph options - span.option-box.pull-right(bo-if='main && list.type=="todo"') + span.option-box.pull-right(ng-if='::main && list.type=="todo"') a.option-action(ng-show='obj.history.todos', ng-click='toggleChart("todos")', tooltip=env.t('progress')) span.glyphicon.glyphicon-signal //a.option-action(ng-href='/v1/users/{{user.id}}/calendar.ics?apiToken={{user.apiToken}}', tooltip='iCal') @@ -20,19 +20,18 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") h2.task-column_title {{list.header}} // Todo Chart - .todos-chart(bo-if='list.type == "todo"', ng-show='charts.todos') + .todos-chart(ng-if='::list.type == "todo"', ng-show='charts.todos') // Add New - form.addtask-form.form-inline.new-task-form(name='new{{list.type}}form', ng-hide='obj._locked || (list.showCompleted && list.type=="todo")', ng-submit='addTask(obj[list.type+"s"],list)') - span.addtask-field - input(type='text', ng-model='list.newTask', placeholder='{{list.placeHolder}}', required) - input.addtask-btn(type='submit', value='+', ng-disabled='new{{list.type}}form.$invalid') - hr + form.task-add(name='new{{list.type}}form', ng-hide='obj._locked || (list.showCompleted && list.type=="todo")', ng-submit='addTask(obj[list.type+"s"],list)') + input(type='text', ng-model='list.newTask', placeholder='{{list.placeHolder}}', required) + button(type='submit', ng-disabled='new{{list.type}}form.$invalid') + span.glyphicon.glyphicon-plus mixin taskColumnTabs(position) // Habits Tabs - div(bo-if='main && list.type=="habit"', class='tabbable tabs-below') - ul.nav.nav-tabs + div(ng-if='::main && list.type=="habit"', class='tabbable tabs-below') + ul.task-filter li(ng-class='{active: list.view == "all"}') a(ng-click='list.view = "all"')=env.t('all') li(ng-class='{active: list.view == "yellowred"}') @@ -40,9 +39,9 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") li(ng-class='{active: list.view == "greenblue"}') a(ng-click='list.view = "greenblue"')=env.t('greenblue') // Daily Tabs - div(bo-if='main && list.type=="daily"', class='tabbable tabs-below') + div(ng-if='::main && list.type=="daily"', class='tabbable tabs-below') // remaining/completed tabs - ul.nav.nav-tabs + ul.task-filter li(ng-class='{active: list.view == "all"}') a(ng-click='list.view = "all"')=env.t('all') li(ng-class='{active: list.view == "remaining"}') @@ -50,7 +49,7 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") li(ng-class='{active: list.view == "complete"}') a(ng-click='list.view = "complete"')=env.t('grey') // Todo Tabs - div(bo-if='main && list.type=="todo"', bo-class='{"tabbable tabs-below": list.type=="todo"}') + div(ng-if='::main && list.type=="todo"', ng-class='::{"tabbable tabs-below": list.type=="todo"}') // div(ng-show='list.view == "complete" || list.view == "all"') // li.task.reward-item(ng-if='#{canceler ? "user.stats.buffs."+canceler : "user.items.special."+k+">0"}',popover-trigger='mouseenter', popover-placement='top', popover='{{Content.spells.special.#{k}.notes()}}') if position=="bottom" @@ -58,15 +57,16 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") .alert =env.t('lotOfToDos') button.task-action-btn.tile.spacious.bright(ng-click='user.ops.clearCompleted({})',popover=env.t('deleteToDosExplanation'),popover-trigger='mouseenter')=env.t('clearCompleted') + p!=env.t('beeminderDeleteWarning') // remaining/completed tabs - ul.nav.nav-tabs + ul.task-filter li(ng-class='{active: !list.showCompleted}') a(ng-click='list.showCompleted = false')=env.t('remaining') li(ng-class='{active: list.showCompleted}') a(ng-click='list.showCompleted= true')=env.t('complete') // Rewards Tabs - div(bo-if='main && list.type=="reward"', class='tabbable tabs-below') - ul.nav.nav-tabs + div(ng-if='::main && list.type=="reward"', class='tabbable tabs-below') + ul.task-filter li(ng-class='{active: list.view == "all"}') a(ng-click='list.view = "all"')=env.t('all') li(ng-class='{active: list.view == "ingamerewards"}') @@ -91,7 +91,7 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") span.reward-cost {{item.value}} span.shop_gold // main content - span(bo-class='{"shop_{{item.key}} shop-sprite item-img": true}').reward-img + span(ng-class='::{"shop_{{item.key}} shop-sprite item-img": true}').reward-img p.task-text {{item.text()}} // Events @@ -139,7 +139,7 @@ script(id='templates/habitrpg-tasks.html', type="text/ng-template") br // Ads - div(bo-if='main && !user.purchased.ads && !user.purchased.plan.customerId && list.type!="reward"') + div(ng-if='::main && !user.purchased.ads && !user.purchased.plan.customerId && list.type!="reward"') span.pull-right a(ui-sref='options.settings.subscription', popover=env.t('removeAds'), popover-trigger='mouseenter') span.glyphicon.glyphicon-remove diff --git a/views/shared/tasks/task.jade b/views/shared/tasks/task.jade index 4b00804cc1..aacd405278 100644 --- a/views/shared/tasks/task.jade +++ b/views/shared/tasks/task.jade @@ -1,4 +1,4 @@ -li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", data-popover-placement="top", ng-show='shouldShow(task, list, user.preferences)') +li(bindonce='list', id='task-{{::task.id}}', ng-repeat='task in obj[list.type+"s"]', class='task {{Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main)}}', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-class='{"cast-target":spell && (list.type != "reward")}', popover-trigger='mouseenter', data-popover-html="{{task.notes | markdown}}", data-popover-placement="top", ng-show='shouldShow(task, list, user.preferences)') // right-hand side control buttons .task-meta-controls @@ -60,7 +60,7 @@ li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s .task-controls.task-primary(ng-if='!task._editing') // Habits - span(bo-if='task.type=="habit"') + span(ng-if='::task.type=="habit"') // score() is overridden in challengesCtrl to do nothing a.task-action-btn(ng-if='task.up', ng-click='score(task,"up")') + a.task-action-btn(ng-if='task.down', ng-click='score(task,"down")') - @@ -72,7 +72,7 @@ li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s span.shop_gold // Daily & Todos - span.task-checker.action-yesno(bo-if='task.type=="daily" || task.type=="todo"') + span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') input.visuallyhidden.focusable(ng-if='$state.includes("tasks")', id='box-{{obj._id}}_{{task.id}}', type='checkbox', ng-model='task.completed', ng-change='task.type=="todo" && pushTask(task,$index,"bottom"); changeCheck(task)') input.visuallyhidden.focusable(ng-if='!$state.includes("tasks")', id='box-{{obj._id}}_{{task.id}}', type='checkbox') label(for='box-{{obj._id}}_{{task.id}}') @@ -163,17 +163,17 @@ li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s label(for='{{obj._id}}_{{task.id}}-option-minus') // if Daily, calendar - fieldset(bo-if='task.type=="daily"', class="option-group") + fieldset(ng-if='::task.type=="daily"', class="option-group") legend.option-title=env.t('repeat') .task-controls.tile-group.repeat-days(bindonce) // note, does not use data-toggle="buttons-checkbox" - it would interfere with our own click binding - button.task-action-btn.tile(ng-class='{active: task.repeat.su}', type='button', ng-click='task.challenge.id || (task.repeat.su = !task.repeat.su)', bo-text='moment.weekdaysMin(0)') - button.task-action-btn.tile(ng-class='{active: task.repeat.m}', type='button', ng-click='task.challenge.id || (task.repeat.m = !task.repeat.m)', bo-text='moment.weekdaysMin(1)') - button.task-action-btn.tile(ng-class='{active: task.repeat.t}', type='button', ng-click='task.challenge.id || (task.repeat.t = !task.repeat.t)', bo-text='moment.weekdaysMin(2)') - button.task-action-btn.tile(ng-class='{active: task.repeat.w}', type='button', ng-click='task.challenge.id || (task.repeat.w = !task.repeat.w)', bo-text='moment.weekdaysMin(3)') - button.task-action-btn.tile(ng-class='{active: task.repeat.th}', type='button', ng-click='task.challenge.id || (task.repeat.th = !task.repeat.th)', bo-text='moment.weekdaysMin(4)') - button.task-action-btn.tile(ng-class='{active: task.repeat.f}', type='button', ng-click='task.challenge.id || (task.repeat.f= !task.repeat.f)', bo-text='moment.weekdaysMin(5)') - button.task-action-btn.tile(ng-class='{active: task.repeat.s}', type='button', ng-click='task.challenge.id || (task.repeat.s = !task.repeat.s)', bo-text='moment.weekdaysMin(6)') + button.task-action-btn.tile(ng-class='{active: task.repeat.su}', type='button', ng-click='task.challenge.id || (task.repeat.su = !task.repeat.su)') {{::moment.weekdaysMin(0)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.m}', type='button', ng-click='task.challenge.id || (task.repeat.m = !task.repeat.m)') {{::moment.weekdaysMin(1)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.t}', type='button', ng-click='task.challenge.id || (task.repeat.t = !task.repeat.t)') {{::moment.weekdaysMin(2)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.w}', type='button', ng-click='task.challenge.id || (task.repeat.w = !task.repeat.w)') {{::moment.weekdaysMin(3)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.th}', type='button', ng-click='task.challenge.id || (task.repeat.th = !task.repeat.th)') {{::moment.weekdaysMin(4)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.f}', type='button', ng-click='task.challenge.id || (task.repeat.f= !task.repeat.f)') {{::moment.weekdaysMin(5)}} + button.task-action-btn.tile(ng-class='{active: task.repeat.s}', type='button', ng-click='task.challenge.id || (task.repeat.s = !task.repeat.s)') {{::moment.weekdaysMin(6)}} // if Reward, pricing fieldset.option-group.option-short(ng-if='task.type=="reward" && !task.challenge.id') @@ -185,7 +185,7 @@ li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s // if Todos, the due date fieldset.option-group(ng-if='task.type=="todo" && !task.challenge.id') legend.option-title=env.t('dueDate') - input.option-content.datepicker(type='text', datepicker-popup='MM/dd/yyyy', ng-model='task.date') + input.option-content.datepicker(type='text', datepicker-popup='MM/dd/yyyy', ng-model='task.date', is-open='datepickerOpened', ng-click='datepickerOpened = true') // Tags fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') @@ -195,7 +195,7 @@ li(bindonce='list', bo-id='"task-"+task.id', ng-repeat='task in obj[list.type+"s markdown(ng-model='tag.name') // Advanced Options - span(bo-if='task.type!="reward"') + span(ng-if='::task.type!="reward"') p.option-title.mega(ng-click='task._advanced = !task._advanced', tooltip=env.t('expandCollapse'))=env.t('advancedOptions') fieldset.option-group.advanced-option(ng-class="{visuallyhidden: task._advanced}") legend.option-title diff --git a/views/static/community-guidelines.jade b/views/static/community-guidelines.jade index 8fcf9a259a..845e3be458 100644 --- a/views/static/community-guidelines.jade +++ b/views/static/community-guidelines.jade @@ -42,22 +42,10 @@ block content div(class='clearfix') img(class='pull-right', src='/community-guidelines-images/beingHabitican.png', alt='') ul - li - strong=env.t('commGuideList01A') - |  - =env.t('commGuideList01Apart2') - li - strong=env.t('commGuideList01B') - |  - =env.t('commGuideList01Bpart2') - li - strong=env.t('commGuideList01C') - |  - =env.t('commGuideList01Cpart2') - li - strong=env.t('commGuideList01D') - |  - =env.t('commGuideList01Dpart2') + li!=env.t('commGuideList01A') + li!=env.t('commGuideList01B') + li!=env.t('commGuideList01C') + li!=env.t('commGuideList01D') h2=env.t('commGuideHeadingMeet') p=env.t('commGuidePara006') @@ -109,12 +97,7 @@ block content strong Megan li strong Daniel the Bard - - p=env.t('commGuidePara012') - | ( - a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com - |). - + p!=env.t('commGuidePara012') p=env.t('commGuidePara013') p=env.t('commGuidePara014') |  @@ -124,49 +107,17 @@ block content img(class='pull-right', src='/community-guidelines-images/publicSpaces.png', alt='') p=env.t('commGuidePara015') p=env.t('commGuidePara016') - p - strong=env.t('commGuidePara017') - |  - =env.t('commGuidePara017part2') + p!=env.t('commGuidePara017') ul - li - strong=env.t('commGuideList02A') - li - strong=env.t('commGuideList02B') - |  - =env.t('commGuideList02Bpart2') - li - strong=env.t('commGuideList02C') - |  - =env.t('commGuideList02Cpart2') - li - strong=env.t('commGuideList02D') - |  - =env.t('commGuideList02Dpart2') - li - strong=env.t('commGuideList02E') - |  - =env.t('commGuideList02Epart2') - li - strong=env.t('commGuideList02F') - |  - =env.t('commGuideList02Fpart2') - li - strong=env.t('commGuideList02G') - |  - =env.t('commGuideList02Gpart2') - li - strong=env.t('commGuideList02H') - |  - =env.t('commGuideList02Hpart2') - |  - a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com - |  - =env.t('commGuideList02Hpart3') - p - strong=env.t('commGuidePara019') - |  - =env.t('commGuidePara019part2') + li!=env.t('commGuideList02A') + li!=env.t('commGuideList02B') + li!=env.t('commGuideList02C') + li!=env.t('commGuideList02D') + li!=env.t('commGuideList02E') + li!=env.t('commGuideList02F') + li!=env.t('commGuideList02G') + li!=env.t('commGuideList02H') + p!=env.t('commGuidePara019') p=env.t('commGuidePara021') h3=env.t('commGuideHeadingTavern') @@ -175,112 +126,43 @@ block content p=env.t('commGuidePara022') p strong=env.t('commGuidePara023') - p=env.t('commGuidePara024') - |  - strong=env.t('commGuidePara024part2') - |  - =env.t('commGuidePara024part3') - p - strong=env.t('commGuidePara027') - |  - =env.t('commGuidePara027part2') + p!=env.t('commGuidePara024') + p!=env.t('commGuidePara027') h3=env.t('commGuideHeadingPublicGuilds') div(class='clearfix') img(class='pull-right', src='/community-guidelines-images/publicGuilds.png', alt='') - p - strong=env.t('commGuidePara029') - |  - =env.t('commGuidePara029part2') - |  - strong=env.t('commGuidePara029part3') - p - strong=env.t('commGuidePara031') - |  - =env.t('commGuidePara031part2') - p - strong=env.t('commGuidePara033') - |  - =env.t('commGuidePara033part2') - |  - a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com - |  - =env.t('commGuidePara033part3') - p - strong=env.t('commGuidePara035') - |  - =env.t('commGuidePara035part2') + p!=env.t('commGuidePara029') + p!=env.t('commGuidePara031') + p!=env.t('commGuidePara033') + p!=env.t('commGuidePara035') p strong=env.t('commGuidePara037') h3=env.t('commGuideHeadingBackCorner') div(class='clearfix') img(class='pull-left', src='/community-guidelines-images/backCorner.png', alt='') - p - strong=env.t('commGuidePara038') - |  - =env.t('commGuidePara038part2') - p=env.t('commGuidePara039') - |  - strong=env.t('commGuidePara039part2') - |  - =env.t('commGuidePara039part3') + p!=env.t('commGuidePara038') + p!=env.t('commGuidePara039') h3=env.t('commGuideHeadingTrello') div(class='clearfix') img(class='pull-right', src='/community-guidelines-images/trello.png', alt='') - p - strong=env.t('commGuidePara040') - |  - =env.t('commGuidePara040part2') - |  - strong=env.t('commGuidePara040part3') - |  - =env.t('commGuidePara040part4') + p!=env.t('commGuidePara040') p strong=env.t('commGuidePara041') ul - li=env.t('The') - |  - strong=env.t('commGuideList03A') - |  - =env.t('commGuideList03Apart2') - li=env.t('The') - |  - strong=env.t('commGuideList03B') - |  - =env.t('commGuideList03Bpart2') - li=env.t('The') - |  - strong=env.t('commGuideList03C') - |  - =env.t('commGuideList03Cpart2') - li=env.t('The') - |  - strong=env.t('commGuideList03D') - |  - =env.t('commGuideList03Dpart2') - li=env.t('The') - |  - strong=env.t('commGuideList03E') - |  - =env.t('commGuideList03Epart2') - p - strong=env.t('commGuidePara042') - |  - =env.t('commGuidePara042part2') + li!=env.t('commGuideList03A') + li!=env.t('commGuideList03B') + li!=env.t('commGuideList03C') + li!=env.t('commGuideList03D') + li!=env.t('commGuideList03E') + p!=env.t('commGuidePara042') h3=env.t('commGuideHeadingGitHub') div(class='clearfix') img(class='pull-left', src='/community-guidelines-images/github.gif', alt='') - p - strong=env.t('commGuidePara043') - |  - =env.t('commGuidePara043part2') - |  - strong=env.t('commGuidePara043part3') - |  - =env.t('commGuidePara043part4') + p!=env.t('commGuidePara043') p strong=env.t('commGuidePara044') ul(class='listColumns2 peopleList') @@ -304,10 +186,7 @@ block content h3=env.t('commGuideHeadingWiki') div(class='clearfix') img(class='pull-right', src='/community-guidelines-images/wiki.png', alt='') - p - strong=env.t('commGuidePara045') - |  - =env.t('commGuidePara045part2') + p!=env.t('commGuidePara045') p=env.t('commGuidePara046') p strong=env.t('commGuidePara047') @@ -338,10 +217,7 @@ block content div(class='clearfix') img(class='pull-left', src='/community-guidelines-images/infractions.png', alt='') p=env.t('commGuidePara050') - p - strong=env.t('commGuidePara051') - |  - =env.t('commGuidePara051part2') + p!=env.t('commGuidePara051') h4=env.t('commGuideHeadingSevereInfractions') p=env.t('commGuidePara052') p=env.t('commGuidePara053') @@ -355,10 +231,7 @@ block content p=env.t('commGuidePara054') p=env.t('commGuidePara055') ul - li=env.t('commGuideList06A') - | ( - a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com - |). + li!=env.t('commGuideList06A') li=env.t('commGuideList06B') li=env.t('commGuideList06C') li=env.t('commGuideList06D') @@ -373,10 +246,7 @@ block content div(class='clearfix') img(class='pull-right', src='/community-guidelines-images/consequences.png', alt='') p=env.t('commGuidePara058') - p - strong=env.t('commGuidePara059') - |  - =env.t('commGuidePara059part2') + p!=env.t('commGuidePara059') p strong=env.t('commGuidePara060') ul @@ -407,16 +277,9 @@ block content h3=env.t('commGuideHeadingRestoration') div(class='clearfix') img(class='pull-left', src='/community-guidelines-images/restoration.png', alt='') - p=env.t('commGuidePara061') - |  - strong=env.t('commGuidePara061part2') - p=env.t('commGuidePara062') - |  - strong=env.t('commGuidePara062part2') - p - strong=env.t('commGuidePara063') - |  - =env.t('commGuidePara063part2') + p!=env.t('commGuidePara061') + p!=env.t('commGuidePara062') + p!=env.t('commGuidePara063') h2=env.t('commGuideHeadingContributing') div(class='clearfix') @@ -433,29 +296,13 @@ block content p=env.t('commGuidePara065') p=env.t('commGuidePara066') ul - li - strong=env.t('commGuideList13A') - |  - =env.t('commGuideList13Apart2') - li - strong=env.t('commGuideList13B') - |  - =env.t('commGuideList13Bpart2') - li - strong=env.t('commGuideList13C') - |  - =env.t('commGuideList13Cpart2') - li - strong=env.t('commGuideList13D') - |  - =env.t('commGuideList13Dpart2') + li!=env.t('commGuideList13A') + li!=env.t('commGuideList13B') + li!=env.t('commGuideList13C') + li!=env.t('commGuideList13D') h2=env.t('commGuideHeadingFinal') - p=env.t('commGuidePara067') - | ( - a(href='mailto:leslie@habitrpg.com') leslie@habitrpg.com - |)  - =env.t('commGuidePara067part2') + p!=env.t('commGuidePara067') p=env.t('commGuidePara068') h2=env.t('commGuideHeadingLinks') diff --git a/views/static/front.jade b/views/static/front.jade index 2e03b1fef6..9927ef3551 100644 --- a/views/static/front.jade +++ b/views/static/front.jade @@ -8,41 +8,45 @@ block title title=env.t('titleFront') block content - .marketing - //we need to use something that's not jumbotron for this, but still keep it centered - //could someone write something else to make it pretty? - img(src='/bower_components/habitrpg-shared/img/logo/habitrpg_pixel.png', alt='HabitRPG logo') - //this image needs to be replaced by something more enticing, that shows off the features of hRPG - //while acting similarly to a logo - h1#tagline=env.t('tagline') - p.lead - button.btn.btn-primary.btn-lg#frontpage-play-button(ng-click='playButtonClick()')=env.t('playButton') - hr - img(src='/marketing/devices.png') - //we'd want the tagline centered, for sure, and a bit more pop, but without using jumbotron - //it could also be part of the image, as long as the alt text included it - //in fact, I think I really want it on the image, rather than as text, but language issues - br - p.lead=env.t('landingp1') - h2=env.t('landingp2header') - //images in these parts could be useful, too - //if there's a language workaround, image headers? people like pictures! - p.lead - =env.t('landingp2') - |  - h2=env.t('landingp3header') - //I'm not sold on "Consquences as the title here. Anyone got a better idea? - p.lead - =env.t('landingp3') - |  - h2=env.t('landingp4header') - p.lead=env.t('landingp4') - //- TODO - h2=env.t('landingend') + div(ng-controller='RootCtrl') + include ../shared/header/avatar + include ../shared/modals/members + + .marketing + //we need to use something that's not jumbotron for this, but still keep it centered + //could someone write something else to make it pretty? + img(src='/bower_components/habitrpg-shared/img/logo/habitrpg_pixel.png', alt='HabitRPG logo') + //this image needs to be replaced by something more enticing, that shows off the features of hRPG + //while acting similarly to a logo + h1#tagline=env.t('tagline') p.lead - =env.t('landingend2') - a(href="FEATURESPAGEHERE")=env.t('landingfeatureslink') - =env.t('landingend3') - a(href="ENTERPRISEPAGEHERE")=env.t('landingadminlink') - |  - =env.t('landingend4') + button.btn.btn-primary.btn-lg#frontpage-play-button(ng-click='playButtonClick()')=env.t('playButton') + hr + img(src='/marketing/devices.png') + //we'd want the tagline centered, for sure, and a bit more pop, but without using jumbotron + //it could also be part of the image, as long as the alt text included it + //in fact, I think I really want it on the image, rather than as text, but language issues + br + p.lead=env.t('landingp1') + h2=env.t('landingp2header') + //images in these parts could be useful, too + //if there's a language workaround, image headers? people like pictures! + p.lead + =env.t('landingp2') + |  + h2=env.t('landingp3header') + //I'm not sold on "Consquences as the title here. Anyone got a better idea? + p.lead + =env.t('landingp3') + |  + h2=env.t('landingp4header') + p.lead=env.t('landingp4') + //- TODO + h2=env.t('landingend') + p.lead + =env.t('landingend2') + a(href="FEATURESPAGEHERE")=env.t('landingfeatureslink') + =env.t('landingend3') + a(href="ENTERPRISEPAGEHERE")=env.t('landingadminlink') + |  + =env.t('landingend4') diff --git a/views/static/login-modal.jade b/views/static/login-modal.jade index 5332b972b8..27e82df49e 100644 --- a/views/static/login-modal.jade +++ b/views/static/login-modal.jade @@ -3,10 +3,15 @@ script(id='modals/login.html', type='text/ng-template') button.close(type='button', ng-click='$close()') × h4.modal-title=env.t('loginAndReg') .modal-body(ng-controller='AuthCtrl') - a(href='/auth/facebook') - img(src='/bower_components/habitrpg-shared/img/facebook-login-register.jpeg', alt=env.t('loginFacebookAlt')) - //can we add in google auth? I like google auth - h3=env.t('or') + a.zocial.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")')=env.t('loginFacebookAlt') + //-ul.list-inline + li + a.zocial.icon.facebook(alt=env.t('loginFacebookAlt'), ng-click='socialLogin("facebook")') + li + a.zocial.icon.googleplus(alt="Google", ng-click='socialLogin("google")') Google+ + li + a.zocial.icon.twitter(alt="Twitter", ng-click='socialLogin("twitter")') Twitter + hr ul.nav.nav-tabs li.active a(data-toggle='tab',data-target='#login-tab')=env.t('login')