Compare commits

...

9 Commits

Author SHA1 Message Date
Sabe Jones
54f57445da 4.5.0 2017-10-23 22:40:41 +00:00
Sabe Jones
95ef2b1789 Merge branch 'develop' into release 2017-10-23 22:40:30 +00:00
Keith Holliday
4d32977e5c Quick fix (#9257)
* Removed hard refresh

* Changed sorting to happen during compute
2017-10-23 17:27:26 -05:00
Keith Holliday
7fe2504906 Bulk purchasing (#9196)
* Moved buy tests

* Added mystery buy to buy.js

* Added quest purchasing to buy

* Added buy special

* Moved integration tests to buy folder

* Removed buyGear dependency

* Removed buyArmoire dependency

* Removed buyHealthPotion dependency

* Removed myster, quest and special dependency

* Replaced functions with factory

* Added bulk purchasing to common

* Added bulk purchasing to the api

* Added bulk purchasing to client

* Refactored purchasing function to reduce long method

* Added bulk purchase to gem purchases

* Added bulk purchasing to api

* Added bulk purchasing to gem items on client

* Removed bulk from equipment

* Removed recentlyPurchased

* Fixed style issues and prevented puchasing more gems than are left

* Fixed lint

* Fixed missing keys

* Fixed gem amount notice

* Added quest modal to pinned item

* Added bulk purchase to gem modal

* Fixed styles

* Fixed bulk purchase for spells

* Fixed modal size

* Hid autofill
2017-10-23 16:05:35 -05:00
Matteo Pagliazzi
b74cee3d21 API Token Changes (#9202)
* hide API token by default

* wip

* add route to reset the api token

* remove dead code
2017-10-23 22:58:33 +02:00
Keith Holliday
5af7733150 Task order fixes (#9255)
* Reset todo task order

* Disabled sorting on todo due

* Revert to task order

* Fixed task sorting sync with server
2017-10-23 12:59:52 -05:00
Keith Holliday
824bf62e0a Force refresh is server version is updated (#9239)
* Force refresh is server version is updated

* Added reload true

* Added confirmation of update

* Forced refresh on cron

* Updated response tests
2017-10-23 12:58:11 -05:00
Keith Holliday
ac24a5dddd Moved ponytails to styles (#9254) 2017-10-22 15:48:50 -05:00
Alys
9111f59da4 change price for backgrounds set from 5 to 15 2017-10-22 09:24:32 +10:00
42 changed files with 703 additions and 317 deletions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "habitica",
"version": "4.4.5",
"version": "4.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "4.4.5",
"version": "4.5.0",
"main": "./website/server/index.js",
"dependencies": {
"@slack/client": "^3.8.1",

View File

@@ -98,4 +98,24 @@ describe('POST /user/purchase/:type/:key', () => {
await members[0].sync();
expect(members[0].balance).to.equal(oldBalance);
});
describe('bulk purchasing', () => {
it('purchases a gem item', async () => {
await user.post(`/user/purchase/${type}/${key}`, {quantity: 2});
await user.sync();
expect(user.items[type][key]).to.equal(2);
});
it('can convert gold to gems if subscribed', async () => {
let oldBalance = user.balance;
await user.update({
'purchased.plan.customerId': 'group-plan',
'stats.gp': 1000,
});
await user.post('/user/purchase/gems/gem', {quantity: 2});
await user.sync();
expect(user.balance).to.equal(oldBalance + 0.50);
});
});
});

View File

@@ -3,8 +3,8 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import shared from '../../../../../website/common/script';
} from '../../../../../helpers/api-integration/v3';
import shared from '../../../../../../website/common/script';
let content = shared.content;
@@ -82,4 +82,19 @@ describe('POST /user/buy/:key', () => {
itemText: item.text(),
}));
});
it('allows for bulk purchases', async () => {
await user.update({
'stats.gp': 400,
'stats.hp': 20,
});
let potion = content.potion;
let res = await user.post('/user/buy/potion', {quantity: 2});
await user.sync();
expect(user.stats.hp).to.equal(50);
expect(res.data).to.eql(user.stats);
expect(res.message).to.equal(t('messageBought', {itemText: potion.text()}));
});
});

View File

@@ -1,7 +1,7 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
} from '../../../../../helpers/api-integration/v3';
describe('POST /user/buy-armoire', () => {
let user;

View File

@@ -3,7 +3,7 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
} from '../../../../../helpers/api-integration/v3';
describe('POST /user/buy-gear/:key', () => {
let user;

View File

@@ -1,8 +1,8 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import shared from '../../../../../website/common/script';
} from '../../../../../helpers/api-integration/v3';
import shared from '../../../../../../website/common/script';
let content = shared.content;

View File

@@ -1,7 +1,7 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
} from '../../../../../helpers/api-integration/v3';
describe('POST /user/buy-mystery-set/:key', () => {
let user;

View File

@@ -1,8 +1,8 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import shared from '../../../../../website/common/script';
} from '../../../../../helpers/api-integration/v3';
import shared from '../../../../../../website/common/script';
let content = shared.content;

View File

@@ -1,8 +1,8 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import shared from '../../../../../website/common/script';
} from '../../../../../helpers/api-integration/v3';
import shared from '../../../../../../website/common/script';
let content = shared.content;

View File

@@ -4,6 +4,7 @@ import {
generateNext,
} from '../../../../helpers/api-unit.helper';
import responseMiddleware from '../../../../../website/server/middlewares/response';
import packageInfo from '../../../../../package.json';
describe('response middleware', () => {
let res, req, next;
@@ -34,6 +35,7 @@ describe('response middleware', () => {
data: {field: 1},
notifications: [],
userV: res.locals.user._v,
appVersion: packageInfo.version,
});
});
@@ -51,6 +53,7 @@ describe('response middleware', () => {
message: 'hello',
notifications: [],
userV: res.locals.user._v,
appVersion: packageInfo.version,
});
});
@@ -67,6 +70,7 @@ describe('response middleware', () => {
data: {field: 1},
notifications: [],
userV: res.locals.user._v,
appVersion: packageInfo.version,
});
});
@@ -81,6 +85,7 @@ describe('response middleware', () => {
data: {field: 1},
notifications: [],
userV: 0,
appVersion: packageInfo.version,
});
});
@@ -104,6 +109,7 @@ describe('response middleware', () => {
},
],
userV: res.locals.user._v,
appVersion: packageInfo.version,
});
});
});

View File

@@ -1,61 +0,0 @@
/* eslint-disable camelcase */
import {
generateUser,
} from '../../helpers/common.helper';
import buy from '../../../website/common/script/ops/buy';
import {
BadRequest,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
describe('shared.ops.buy', () => {
let user;
beforeEach(() => {
user = generateUser({
items: {
gear: {
owned: {
weapon_warrior_0: true,
},
equipped: {
weapon_warrior_0: true,
},
},
},
stats: { gp: 200 },
});
});
it('returns error when key is not provided', (done) => {
try {
buy(user);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('missingKeyParam'));
done();
}
});
it('recovers 15 hp', () => {
user.stats.hp = 30;
buy(user, {params: {key: 'potion'}});
expect(user.stats.hp).to.eql(45);
});
it('adds equipment to inventory', () => {
user.stats.gp = 31;
buy(user, {params: {key: 'armor_warrior_1'}});
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
armor_warrior_1: true,
eyewear_special_blackTopFrame: true,
eyewear_special_blueTopFrame: true,
eyewear_special_greenTopFrame: true,
eyewear_special_pinkTopFrame: true,
eyewear_special_redTopFrame: true,
eyewear_special_whiteTopFrame: true,
eyewear_special_yellowTopFrame: true,
});
});
});

124
test/common/ops/buy/buy.js Normal file
View File

@@ -0,0 +1,124 @@
/* eslint-disable camelcase */
import {
generateUser,
} from '../../../helpers/common.helper';
import buy from '../../../../website/common/script/ops/buy';
import {
BadRequest,
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
import content from '../../../../website/common/script/content/index';
describe('shared.ops.buy', () => {
let user;
beforeEach(() => {
user = generateUser({
items: {
gear: {
owned: {
weapon_warrior_0: true,
},
equipped: {
weapon_warrior_0: true,
},
},
},
stats: { gp: 200 },
});
});
it('returns error when key is not provided', (done) => {
try {
buy(user);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(err.message).to.equal(i18n.t('missingKeyParam'));
done();
}
});
it('recovers 15 hp', () => {
user.stats.hp = 30;
buy(user, {params: {key: 'potion'}});
expect(user.stats.hp).to.eql(45);
});
it('adds equipment to inventory', () => {
user.stats.gp = 31;
buy(user, {params: {key: 'armor_warrior_1'}});
expect(user.items.gear.owned).to.eql({
weapon_warrior_0: true,
armor_warrior_1: true,
eyewear_special_blackTopFrame: true,
eyewear_special_blueTopFrame: true,
eyewear_special_greenTopFrame: true,
eyewear_special_pinkTopFrame: true,
eyewear_special_redTopFrame: true,
eyewear_special_whiteTopFrame: true,
eyewear_special_yellowTopFrame: true,
});
});
it('buys Steampunk Accessories Set', () => {
user.purchased.plan.consecutive.trinkets = 1;
buy(user, {
params: {
key: '301404',
},
type: 'mystery',
});
expect(user.purchased.plan.consecutive.trinkets).to.eql(0);
expect(user.items.gear.owned).to.have.property('weapon_warrior_0', true);
expect(user.items.gear.owned).to.have.property('weapon_mystery_301404', true);
expect(user.items.gear.owned).to.have.property('armor_mystery_301404', true);
expect(user.items.gear.owned).to.have.property('head_mystery_301404', true);
expect(user.items.gear.owned).to.have.property('eyewear_mystery_301404', true);
});
it('buys a Quest scroll', () => {
user.stats.gp = 205;
buy(user, {
params: {
key: 'dilatoryDistress1',
},
type: 'quest',
});
expect(user.items.quests).to.eql({dilatoryDistress1: 1});
expect(user.stats.gp).to.equal(5);
});
it('buys a special item', () => {
user.stats.gp = 11;
let item = content.special.thankyou;
let [data, message] = buy(user, {
params: {
key: 'thankyou',
},
type: 'special',
});
expect(user.stats.gp).to.equal(1);
expect(user.items.special.thankyou).to.equal(1);
expect(data).to.eql({
items: user.items,
stats: user.stats,
});
expect(message).to.equal(i18n.t('messageBought', {
itemText: item.text(),
}));
});
it('allows for bulk purchases', () => {
user.stats.hp = 30;
buy(user, {params: {key: 'potion'}, quantity: 2});
expect(user.stats.hp).to.eql(50);
});
});

View File

@@ -2,15 +2,15 @@
import {
generateUser,
} from '../../helpers/common.helper';
import count from '../../../website/common/script/count';
import buyArmoire from '../../../website/common/script/ops/buyArmoire';
import randomVal from '../../../website/common/script/libs/randomVal';
import content from '../../../website/common/script/content/index';
} from '../../../helpers/common.helper';
import count from '../../../../website/common/script/count';
import buyArmoire from '../../../../website/common/script/ops/buyArmoire';
import randomVal from '../../../../website/common/script/libs/randomVal';
import content from '../../../../website/common/script/content/index';
import {
NotAuthorized,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
function getFullArmoire () {
let fullArmoire = {};

View File

@@ -3,13 +3,13 @@
import sinon from 'sinon'; // eslint-disable-line no-shadow
import {
generateUser,
} from '../../helpers/common.helper';
import buyGear from '../../../website/common/script/ops/buyGear';
import shared from '../../../website/common/script';
} from '../../../helpers/common.helper';
import buyGear from '../../../../website/common/script/ops/buyGear';
import shared from '../../../../website/common/script';
import {
NotAuthorized,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyGear', () => {
let user;

View File

@@ -1,12 +1,12 @@
/* eslint-disable camelcase */
import {
generateUser,
} from '../../helpers/common.helper';
import buyHealthPotion from '../../../website/common/script/ops/buyHealthPotion';
} from '../../../helpers/common.helper';
import buyHealthPotion from '../../../../website/common/script/ops/buyHealthPotion';
import {
NotAuthorized,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyHealthPotion', () => {
let user;

View File

@@ -2,13 +2,13 @@
import {
generateUser,
} from '../../helpers/common.helper';
import buyMysterySet from '../../../website/common/script/ops/buyMysterySet';
} from '../../../helpers/common.helper';
import buyMysterySet from '../../../../website/common/script/ops/buyMysterySet';
import {
NotAuthorized,
NotFound,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyMysterySet', () => {
let user;

View File

@@ -1,12 +1,12 @@
import {
generateUser,
} from '../../helpers/common.helper';
import buyQuest from '../../../website/common/script/ops/buyQuest';
} from '../../../helpers/common.helper';
import buyQuest from '../../../../website/common/script/ops/buyQuest';
import {
NotAuthorized,
NotFound,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
describe('shared.ops.buyQuest', () => {
let user;

View File

@@ -1,14 +1,14 @@
import buySpecialSpell from '../../../website/common/script/ops/buySpecialSpell';
import buySpecialSpell from '../../../../website/common/script/ops/buySpecialSpell';
import {
BadRequest,
NotFound,
NotAuthorized,
} from '../../../website/common/script/libs/errors';
import i18n from '../../../website/common/script/i18n';
} from '../../../../website/common/script/libs/errors';
import i18n from '../../../../website/common/script/i18n';
import {
generateUser,
} from '../../helpers/common.helper';
import content from '../../../website/common/script/content/index';
} from '../../../helpers/common.helper';
import content from '../../../../website/common/script/content/index';
describe('shared.ops.buySpecialSpell', () => {
let user;

View File

@@ -138,6 +138,7 @@ describe('shared.ops.purchase', () => {
user.balance = userGemAmount;
user.stats.gp = goldPoints;
user.purchased.plan.gemsBought = 0;
user.purchased.plan.customerId = 'customer-id';
});
it('purchases gems', () => {
@@ -226,4 +227,39 @@ describe('shared.ops.purchase', () => {
clock.restore();
});
});
context('bulk purchase', () => {
let userGemAmount = 10;
before(() => {
user.balance = userGemAmount;
user.stats.gp = goldPoints;
user.purchased.plan.gemsBought = 0;
user.purchased.plan.customerId = 'customer-id';
});
it('makes bulk purchases of gems', () => {
let [, message] = purchase(user, {
params: {type: 'gems', key: 'gem'},
quantity: 2,
});
expect(message).to.equal(i18n.t('plusOneGem'));
expect(user.balance).to.equal(userGemAmount + 0.50);
expect(user.purchased.plan.gemsBought).to.equal(2);
expect(user.stats.gp).to.equal(goldPoints - planGemLimits.convRate * 2);
});
it('makes bulk purchases of eggs', () => {
let type = 'eggs';
let key = 'TigerCub';
purchase(user, {
params: {type, key},
quantity: 2,
});
expect(user.items[type][key]).to.equal(2);
});
});
});

View File

@@ -191,6 +191,7 @@ export default {
const isApiCall = url.indexOf('api/v3') !== -1;
const userV = response.data && response.data.userV;
const isCron = url.indexOf('/api/v3/cron') === 0 && method === 'post';
if (this.isUserLoaded && isApiCall && userV) {
const oldUserV = this.user._v;
@@ -202,7 +203,6 @@ export default {
// exclude chat seen requests because with real time chat they would be too many
const isChatSeen = url.indexOf('/chat/seen') !== -1 && method === 'post';
// exclude POST /api/v3/cron because the user is synced automatically after cron runs
const isCron = url.indexOf('/api/v3/cron') === 0 && method === 'post';
// Something has changed on the user object that was not tracked here, sync the user
if (userV - oldUserV > 1 && !isCron && !isChatSeen && !isUserSync && !isTasksSync) {
@@ -213,6 +213,21 @@ export default {
}
}
// Verify the client is updated
// const serverAppVersion = response.data.appVersion;
// let serverAppVersionState = this.$store.state.serverAppVersion;
// let deniedUpdate = this.$store.state.deniedUpdate;
// if (isApiCall && !serverAppVersionState) {
// this.$store.state.serverAppVersion = serverAppVersion;
// } else if (isApiCall && serverAppVersionState !== serverAppVersion && !deniedUpdate || isCron) {
// // For reload on cron
// if (isCron || confirm(this.$t('habiticaHasUpdated'))) {
// location.reload(true);
// } else {
// this.$store.state.deniedUpdate = true;
// }
// }
return response;
});

View File

@@ -86,15 +86,12 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`skin.${set.keys.join(",skin.")}`)') {{ $t('purchaseAll') }}
#hair.section.customize-section(v-if='activeTopPage === "hair"')
.row.sub-menu.text-center
.col-3.offset-1.text-center.sub-menu-item(@click='changeSubPage("color")', :class='{active: activeSubPage === "color"}')
.row.col-12.sub-menu.text-center
.col-3.text-center.sub-menu-item(@click='changeSubPage("color")', :class='{active: activeSubPage === "color"}')
strong(v-once) {{$t('color')}}
.col-4.text-center.sub-menu-item(@click='changeSubPage("bangs")', :class='{active: activeSubPage === "bangs"}')
.col-3.text-center.sub-menu-item(@click='changeSubPage("bangs")', :class='{active: activeSubPage === "bangs"}')
strong(v-once) {{$t('bangs')}}
.col-3.text-center.sub-menu-item(@click='changeSubPage("ponytail")', :class='{active: activeSubPage === "ponytail"}')
strong(v-once) {{$t('ponytail')}}
.row.sub-menu.text-center
.col-3.offset-3.text-center.sub-menu-item(@click='changeSubPage("style")', :class='{active: activeSubPage === "style"}', v-if='editing')
.col-3.text-center.sub-menu-item(@click='changeSubPage("style")', :class='{active: activeSubPage === "style"}', v-if='editing')
strong(v-once) {{$t('style')}}
.col-3.text-center.sub-menu-item(@click='changeSubPage("facialhair")', :class='{active: activeSubPage === "facialhair"}', v-if='editing')
strong(v-once) {{$t('facialhair')}}
@@ -141,14 +138,6 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.base.${baseHair4Keys.join(",hair.base.")}`)') {{ $t('purchaseAll') }}
#bangs.row(v-if='activeSubPage === "bangs"')
.col-12.customize-options
.head_0.option(@click='set({"preferences.hair.bangs": 0})',
:class="[{ active: user.preferences.hair.bangs === 0 }, 'hair_bangs_0_' + user.preferences.hair.color]")
.option(v-for='option in ["1", "2", "3", "4"]',
:class='{active: user.preferences.hair.bangs === option}')
.bangs.sprite.customize-option(:class="`hair_bangs_${option}_${user.preferences.hair.color}`", @click='set({"preferences.hair.bangs": option})')
#base-hair.row(v-if='activeSubPage === "ponytail"')
.col-12.customize-options
.head_0.option(@click='set({"preferences.hair.base": 0})', :class="[{ active: user.preferences.hair.base === 0 }, 'hair_base_0_' + user.preferences.hair.color]")
.option(v-for='option in baseHair1',
@@ -166,6 +155,13 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.base.${baseHair2Keys.join(",hair.base.")}`)') {{ $t('purchaseAll') }}
#bangs.row(v-if='activeSubPage === "bangs"')
.col-12.customize-options
.head_0.option(@click='set({"preferences.hair.bangs": 0})',
:class="[{ active: user.preferences.hair.bangs === 0 }, 'hair_bangs_0_' + user.preferences.hair.color]")
.option(v-for='option in ["1", "2", "3", "4"]',
:class='{active: user.preferences.hair.bangs === option}')
.bangs.sprite.customize-option(:class="`hair_bangs_${option}_${user.preferences.hair.color}`", @click='set({"preferences.hair.bangs": option})')
#facialhair.row(v-if='activeSubPage === "facialhair"')
.col-12.customize-options(v-if='editing')
.head_0.option(@click='set({"preferences.hair.beard": 0})', :class="[{ active: user.preferences.hair.beard === 0 }, 'hair_base_0_' + user.preferences.hair.color]")
@@ -285,7 +281,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.col-12.text-center(v-if='!ownsSet("background", set.items) && set.identifier !== "incentiveBackgrounds"')
.gem-amount
.svg-icon.gem(v-html='icons.gem')
span 5
span 15
button.btn.btn-secondary(@click='unlock(setKeys("background", set.items))') Purchase Set
.container.interests-section(v-if='modalPage === 3 && !editing')

View File

@@ -2,14 +2,18 @@
.row.standard-page
.col-6
h2 {{ $t('API') }}
small {{ $t('APIText') }}
p {{ $t('APIText') }}
.section
h6 {{ $t('userId') }}
pre.prettyprint {{user.id}}
h6 {{ $t('APIToken') }}
pre.prettyprint {{apiToken}}
small(v-html='$t("APITokenWarning", { hrefTechAssistanceEmail })')
.d-flex.align-items-center.mb-3
button.btn.btn-secondary(
@click="showApiToken = !showApiToken"
) {{ $t(`${showApiToken ? 'hide' : 'show'}APIToken`) }}
pre.prettyprint.ml-4.mb-0(v-if="showApiToken") {{apiToken}}
p(v-html='$t("APITokenWarning", { hrefTechAssistanceEmail })')
.section
h3 {{ $t('thirdPartyApps') }}
@@ -78,6 +82,7 @@ export default {
url: '',
},
hrefTechAssistanceEmail: `<a href="mailto:${TECH_ASSISTANCE_EMAIL}">${TECH_ASSISTANCE_EMAIL}</a>`,
showApiToken: false,
};
},
computed: {

View File

@@ -115,7 +115,8 @@
div
ul.list-inline
li(v-for='network in SOCIAL_AUTH_NETWORKS')
button.btn.btn-primary(v-if='!user.auth[network.key].id', @click='socialLogin(network.key, user)') {{ $t('registerWithSocial', {network: network.name}) }}
// @TODO this is broken
button.btn.btn-primary(v-if='!user.auth[network.key].id', @click='socialAuth(network.key, user)') {{ $t('registerWithSocial', {network: network.name}) }}
button.btn.btn-primary(disabled='disabled', v-if='!hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('registeredWithSocial', {network: network.name}) }}
button.btn.btn-danger(@click='deleteSocialAuth(network.key)', v-if='hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('detachSocial', {network: network.name}) }}
hr
@@ -377,7 +378,7 @@ export default {
auth,
});
this.$router.go('/tasks');
window.location.href = '/';
},
async changeClassForUser (confirmationNeeded) {
if (confirmationNeeded && !confirm(this.$t('changeClassConfirmCost'))) return;

View File

@@ -41,25 +41,32 @@
:item="item"
)
div(:class="{'notEnough': !this.enoughCurrency(getPriceClass(), item.value)}")
span.svg-icon.inline.icon-32(aria-hidden="true", v-html="icons[getPriceClass()]")
span.value(:class="getPriceClass()") {{ item.value }}
.purchase-amount(:class="{'notEnough': !this.enoughCurrency(getPriceClass(), item.value * selectedAmountToBuy)}")
.how-many-to-buy(v-if='item.purchaseType !== "gear"')
strong {{ $t('howManyToBuy') }}
div(v-if='item.purchaseType !== "gear"')
.box
input(type='number', min='0', v-model='selectedAmountToBuy')
span.svg-icon.inline.icon-32(aria-hidden="true", v-html="icons[getPriceClass()]")
span.value(:class="getPriceClass()") {{ item.value }}
.gems-left(v-if='item.key === "gem"')
strong(v-if='gemsLeft > 0') {{ gemsLeft }} {{ $t('gemsRemaining') }}
strong(v-if='gemsLeft === 0') {{ $t('maxBuyGems') }}
div(v-if='attemptingToPurchaseMoreGemsThanAreLeft')
| {{$t('notEnoughGemsToBuy')}}
button.btn.btn-primary(
@click="purchaseGems()",
v-if="getPriceClass() === 'gems' && !this.enoughCurrency(getPriceClass(), item.value)"
v-if="getPriceClass() === 'gems' && !this.enoughCurrency(getPriceClass(), item.value * selectedAmountToBuy)"
) {{ $t('purchaseGems') }}
button.btn.btn-primary(
@click="buyItem()",
v-else,
:disabled='item.key === "gem" && gemsLeft === 0',
:class="{'notEnough': !preventHealthPotion || !this.enoughCurrency(getPriceClass(), item.value)}"
:disabled='item.key === "gem" && gemsLeft === 0 || attemptingToPurchaseMoreGemsThanAreLeft',
:class="{'notEnough': !preventHealthPotion || !this.enoughCurrency(getPriceClass(), item.value * selectedAmountToBuy)}"
) {{ $t('buyNow') }}
div.limitedTime(v-if="item.event")
@@ -101,6 +108,37 @@
width: 282px;
}
.purchase-amount {
margin-top: 24px;
.how-many-to-buy {
margin-bottom: 16px;
}
.box {
display: inline-block;
width: 74px;
height: 40px;
border-radius: 2px;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
margin-right: 24px;
input {
width: 100%;
border: none;
}
input::-webkit-contacts-auto-fill-button {
visibility: hidden;
display: none !important;
pointer-events: none;
position: absolute;
right: 0;
}
}
}
.content-text {
font-family: 'Roboto', sans-serif;
font-size: 14px;
@@ -217,6 +255,8 @@
<script>
import bModal from 'bootstrap-vue/lib/components/modal';
import bDropdown from 'bootstrap-vue/lib/components/dropdown';
import bDropdownItem from 'bootstrap-vue/lib/components/dropdown-item';
import * as Analytics from 'client/libs/analytics';
import spellsMixin from 'client/mixins/spells';
import planGemLimits from 'common/script/libs/planGemLimits';
@@ -248,6 +288,8 @@
mixins: [currencyMixin, notifications, spellsMixin, buyMixin],
components: {
bModal,
bDropdown,
bDropdownItem,
BalanceInfo,
EquipmentAttributesGrid,
Item,
@@ -264,6 +306,7 @@
clock: svgClock,
}),
selectedAmountToBuy: 1,
isPinned: false,
};
},
@@ -306,10 +349,15 @@
if (!this.user.purchased.plan) return 0;
return planGemLimits.convCap + this.user.purchased.plan.consecutive.gemCapExtra - this.user.purchased.plan.gemsBought;
},
attemptingToPurchaseMoreGemsThanAreLeft () {
if (this.item && this.item.key && this.item.key === 'gem' && this.selectedAmountToBuy > this.gemsLeft) return true;
return false;
},
},
watch: {
item: function itemChanged () {
this.isPinned = this.item && this.item.pinned;
this.selectedAmountToBuy = 1;
},
},
methods: {
@@ -320,7 +368,7 @@
if (this.item.cast) {
this.castStart(this.item);
} else if (this.genericPurchase) {
this.makeGenericPurchase(this.item);
this.makeGenericPurchase(this.item, 'buyModal', this.selectedAmountToBuy);
this.purchased(this.item.text);
}

View File

@@ -355,7 +355,7 @@
}
.gems-left {
.market .gems-left {
position: absolute;
right: -.5em;
top: -.5em;
@@ -739,11 +739,6 @@ export default {
}
},
itemSelected (item) {
if (item.purchaseType !== 'gear' && this.$store.state.recentlyPurchased[item.key]) {
this.makeGenericPurchase(item);
return;
}
this.$root.$emit('buyModal::showItem', item);
},
featuredItemSelected (item) {

View File

@@ -18,20 +18,23 @@
div.inner-content
questDialogContent(:item="item")
div
.purchase-amount
.how-many-to-buy
strong {{ $t('howManyToBuy') }}
.box
input(type='number', min='0', v-model='selectedAmountToBuy')
span.svg-icon.inline.icon-32(aria-hidden="true", v-html="(priceType === 'gems') ? icons.gem : icons.gold")
span.value(:class="priceType") {{ item.value }}
button.btn.btn-primary(
@click="purchaseGems()",
v-if="priceType === 'gems' && !this.enoughCurrency(priceType, item.value)"
v-if="priceType === 'gems' && !this.enoughCurrency(priceType, item.value * selectedAmountToBuy)"
) {{ $t('purchaseGems') }}
button.btn.btn-primary(
@click="buyItem()",
v-else,
:class="{'notEnough': !this.enoughCurrency(priceType, item.value)}"
:class="{'notEnough': !this.enoughCurrency(priceType, item.value * selectedAmountToBuy)}"
) {{ $t('buyNow') }}
div.right-sidebar(v-if="item.drop")
@@ -52,9 +55,12 @@
#buy-quest-modal {
@include centeredModal();
.modal-dialog {
margin-top: 25em;
}
.content {
text-align: center;
max-height: 80vh;
overflow-y: scroll;
}
@@ -167,6 +173,37 @@
pointer-events: none;
opacity: 0.55;
}
.purchase-amount {
margin-top: 24px;
.how-many-to-buy {
margin-bottom: 16px;
}
.box {
display: inline-block;
width: 74px;
height: 40px;
border-radius: 2px;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
margin-right: 24px;
input {
width: 100%;
border: none;
}
input::-webkit-contacts-auto-fill-button {
visibility: hidden;
display: none !important;
pointer-events: none;
position: absolute;
right: 0;
}
}
}
}
</style>
@@ -210,6 +247,7 @@
}),
isPinned: false,
selectedAmountToBuy: 1,
};
},
watch: {
@@ -238,10 +276,11 @@
},
methods: {
onChange ($event) {
this.selectedAmountToBuy = 1;
this.$emit('change', $event);
},
buyItem () {
this.makeGenericPurchase(this.item, 'buyQuestModal');
this.makeGenericPurchase(this.item, 'buyQuestModal', this.selectedAmountToBuy);
this.purchased(this.item.text);
this.hideDialog();
},

View File

@@ -477,11 +477,6 @@ export default {
this.selectedItemToBuy = item;
if (this.$store.state.recentlyPurchased[item.key]) {
this.makeGenericPurchase(item);
return;
}
this.$root.$emit('show::modal', 'buy-quest-modal');
},
},

View File

@@ -502,12 +502,6 @@
},
itemSelected (item) {
if (item.locked) return;
if (this.$store.state.recentlyPurchased[item.key]) {
this.makeGenericPurchase(item);
return;
}
this.$root.$emit('buyModal::showItem', item);
},
},

View File

@@ -1,6 +1,11 @@
<template lang="pug">
.tasks-column(:class='type')
b-modal(ref="editTaskModal")
buy-quest-modal(:item="selectedItemToBuy || {}",
:priceType="selectedItemToBuy ? selectedItemToBuy.currency : ''",
:withPin="true",
@change="resetItemToBuy($event)"
v-if='type === "reward"')
.d-flex
h2.tasks-column-title(v-once) {{ $t(types[type].label) }}
.filters.d-flex.justify-content-end
@@ -15,7 +20,7 @@
v-model="quickAddText", @keyup.enter="quickAdd",
ref="quickAdd",
)
.sortable-tasks(ref="tasksList", v-sortable='', @onsort='sorted')
.sortable-tasks(ref="tasksList", v-sortable='activeFilters[type].label !== "scheduled"', @onsort='sorted', data-sortableId)
task(
v-for="task in taskList",
:key="task.id", :task="task",
@@ -200,6 +205,7 @@ import sortable from 'client/directives/sortable.directive';
import buyMixin from 'client/mixins/buy';
import { mapState, mapActions } from 'client/libs/store';
import shopItem from '../shops/shopItem';
import BuyQuestModal from 'client/components/shops/quests/buyQuestModal.vue';
import { shouldDo } from 'common/script/cron';
import inAppRewards from 'common/script/libs/inAppRewards';
@@ -215,6 +221,7 @@ export default {
mixins: [buyMixin],
components: {
Task,
BuyQuestModal,
bModal,
shopItem,
},
@@ -278,6 +285,8 @@ export default {
forceRefresh: new Date(),
quickAddText: '',
selectedItemToBuy: {},
};
},
computed: {
@@ -288,8 +297,25 @@ export default {
}),
taskList () {
// @TODO: This should not default to user's tasks. It should require that you pass options in
if (this.taskListOverride) return this.taskListOverride;
return this.tasks[`${this.type}s`];
const filter = this.activeFilters[this.type];
let taskList = this.tasks[`${this.type}s`];
if (this.taskListOverride) taskList = this.taskListOverride;
if (taskList.length > 0 && ['scheduled', 'due'].indexOf(filter.label) === -1) {
let taskListSorted = this.$store.dispatch('tasks:order', [
taskList,
this.user.tasksOrder,
]);
taskList = taskListSorted[`${this.type}s`];
}
if (filter.sort) {
taskList = sortBy(taskList, filter.sort);
}
return taskList;
},
inAppRewards () {
let watchRefresh = this.forceRefresh; // eslint-disable-line
@@ -365,19 +391,25 @@ export default {
loadCompletedTodos: 'tasks:fetchCompletedTodos',
createTask: 'tasks:create',
}),
sorted (data) {
async sorted (data) {
const filteredList = this.taskList.filter(this.activeFilters[this.type].filter);
const sorting = this.taskList;
const taskIdToMove = this.taskList[data.oldIndex]._id;
const taskIdToMove = filteredList[data.oldIndex]._id;
// Server
const taskIdToReplace = filteredList[data.newIndex];
const newIndexOnServer = this.taskList.findIndex(taskId => taskId === taskIdToReplace);
let newOrder = await this.$store.dispatch('tasks:move', {
taskId: taskIdToMove,
position: newIndexOnServer,
});
this.user.tasksOrder[`${this.type}s`] = newOrder;
// Client
if (sorting) {
const deleted = sorting.splice(data.oldIndex, 1);
sorting.splice(data.newIndex, 0, deleted[0]);
}
this.$store.dispatch('tasks:move', {
taskId: taskIdToMove,
position: data.newIndex,
});
},
quickAdd () {
const task = taskDefaults({type: this.type, text: this.quickAddText});
@@ -393,10 +425,6 @@ export default {
this.loadCompletedTodos();
}
this.activeFilters[type] = filter;
if (filter.sort) {
this.tasks[`${type}s`] = sortBy(this.tasks[`${type}s`], filter.sort);
}
},
setColumnBackgroundVisibility () {
this.$nextTick(() => {
@@ -465,10 +493,21 @@ export default {
return;
}
if (rewardItem.purchaseType === 'quests') {
this.selectedItemToBuy = rewardItem;
this.$root.$emit('show::modal', 'buy-quest-modal');
return;
}
if (rewardItem.purchaseType !== 'gear' || !rewardItem.locked) {
this.$emit('openBuyDialog', rewardItem);
}
},
resetItemToBuy ($event) {
if (!$event) {
this.selectedItemToBuy = null;
}
},
},
};
</script>

View File

@@ -1,23 +1,45 @@
import Sortable from 'sortablejs';
import uuid from 'uuid';
let emit = (vnode, eventName, data) => {
let handlers = vnode.data && vnode.data.on ||
vnode.componentOptions && vnode.componentOptions.listeners;
let emit = (vNode, eventName, data) => {
let handlers = vNode.data && vNode.data.on ||
vNode.componentOptions && vNode.componentOptions.listeners;
if (handlers && handlers[eventName]) {
handlers[eventName].fns(data);
}
};
let sortableReferences = {};
function createSortable (el, vNode) {
let sortableRef = Sortable.create(el, {
onSort: (evt) => {
emit(vNode, 'onsort', {
oldIndex: evt.oldIndex,
newIndex: evt.newIndex,
});
},
});
let uniqueId = uuid();
sortableReferences[uniqueId] = sortableRef;
el.dataset.sortableId = uniqueId;
}
export default {
bind (el, binding, vnode) {
Sortable.create(el, {
onSort: (evt) => {
emit(vnode, 'onsort', {
oldIndex: evt.oldIndex,
newIndex: evt.newIndex,
});
},
});
bind (el, binding, vNode) {
createSortable(el, vNode);
},
unbind (el) {
if (sortableReferences[el.dataset.sortableId]) sortableReferences[el.dataset.sortableId].destroy();
},
update (el, vNode) {
if (sortableReferences[el.dataset.sortableId] && !vNode.value) {
sortableReferences[el.dataset.sortableId].destroy();
delete sortableReferences[el.dataset.sortableId];
return;
}
if (!sortableReferences[el.dataset.sortableId]) createSortable(el, vNode);
},
};

View File

@@ -1,17 +1,14 @@
export default {
methods: {
makeGenericPurchase (item, type = 'buyModal') {
makeGenericPurchase (item, type = 'buyModal', quantity = 1) {
this.$store.dispatch('shops:genericPurchase', {
pinType: item.pinType,
type: item.purchaseType,
key: item.key,
currency: item.currency,
quantity,
});
if (item.purchaseType !== 'gear') {
this.$store.state.recentlyPurchased[item.key] = true;
}
this.$root.$emit('playSound', 'Reward');
if (type !== 'buyModal') {

View File

@@ -1,18 +1,19 @@
import axios from 'axios';
import buyOp from 'common/script/ops/buy';
import buyQuestOp from 'common/script/ops/buyQuest';
import purchaseOp from 'common/script/ops/purchaseWithSpell';
import buyMysterySetOp from 'common/script/ops/buyMysterySet';
import hourglassPurchaseOp from 'common/script/ops/hourglassPurchase';
import sellOp from 'common/script/ops/sell';
import unlockOp from 'common/script/ops/unlock';
import buyArmoire from 'common/script/ops/buyArmoire';
import rerollOp from 'common/script/ops/reroll';
import { getDropClass } from 'client/libs/notifications';
// @TODO: Purchase means gems and buy means gold. That wording is misused below, but we should also change
// the generic buy functions to something else. Or have a Gold Vendor and Gem Vendor, etc
export function buyItem (store, params) {
const quantity = params.quantity || 1;
const user = store.state.user.data;
let opResult = buyOp(user, {params});
let opResult = buyOp(user, {params, quantity});
return {
result: opResult,
@@ -21,32 +22,77 @@ export function buyItem (store, params) {
}
export function buyQuestItem (store, params) {
const quantity = params.quantity || 1;
const user = store.state.user.data;
let opResult = buyQuestOp(user, {params});
let opResult = buyOp(user, {
params,
type: 'quest',
quantity,
});
return {
result: opResult,
httpCall: axios.post(`/api/v3/user/buy-quest/${params.key}`),
httpCall: axios.post(`/api/v3/user/buy/${params.key}`, {type: 'quest'}),
};
}
async function buyArmoire (store, params) {
const quantity = params.quantity || 1;
let buyResult = buyOp(store.state.user.data, {
params: {
key: 'armoire',
},
type: 'armoire',
quantity,
});
// We need the server result because armoir has random item in the result
let result = await axios.post('/api/v3/user/buy/armoire', {
type: 'armoire',
quantity,
});
buyResult = result.data.data;
if (buyResult) {
const resData = buyResult;
const item = resData.armoire;
const isExperience = item.type === 'experience';
if (item.type === 'gear') {
store.state.user.data.items.gear.owned[item.dropKey] = true;
}
// @TODO: We might need to abstract notifications to library rather than mixin
store.dispatch('snackbars:add', {
title: '',
text: isExperience ? item.value : item.dropText,
type: isExperience ? 'xp' : 'drop',
icon: isExperience ? null : getDropClass({type: item.type, key: item.dropKey}),
timeout: true,
});
}
}
export function purchase (store, params) {
const quantity = params.quantity || 1;
const user = store.state.user.data;
let opResult = purchaseOp(user, {params});
let opResult = purchaseOp(user, {params, quantity});
return {
result: opResult,
httpCall: axios.post(`/api/v3/user/purchase/${params.type}/${params.key}`),
httpCall: axios.post(`/api/v3/user/purchase/${params.type}/${params.key}`, {quantity}),
};
}
export function purchaseMysterySet (store, params) {
const user = store.state.user.data;
let opResult = buyMysterySetOp(user, {params, noConfirm: true});
let opResult = buyOp(user, {params, noConfirm: true, type: 'mystery'});
return {
result: opResult,
httpCall: axios.post(`/api/v3/user/buy-mystery-set/${params.key}`),
httpCall: axios.post(`/api/v3/user/buy/${params.key}`, {type: 'mystery'}),
};
}
@@ -75,32 +121,7 @@ export async function genericPurchase (store, params) {
case 'mystery_set':
return purchaseMysterySet(store, params);
case 'armoire': // eslint-disable-line
let buyResult = buyArmoire(store.state.user.data);
// We need the server result because armoir has random item in the result
let result = await axios.post('/api/v3/user/buy-armoire');
buyResult = result.data.data;
if (buyResult) {
const resData = buyResult;
const item = resData.armoire;
const isExperience = item.type === 'experience';
if (item.type === 'gear') {
store.state.user.data.items.gear.owned[item.dropKey] = true;
}
// @TODO: We might need to abstract notifications to library rather than mixin
store.dispatch('snackbars:add', {
title: '',
text: isExperience ? item.value : item.dropText,
type: isExperience ? 'xp' : 'drop',
icon: isExperience ? null : getDropClass({type: item.type, key: item.dropKey}),
timeout: true,
});
}
await buyArmoire(store, params);
return;
case 'fortify': {
let rerollResult = rerollOp(store.state.user.data);
@@ -134,9 +155,5 @@ export async function genericPurchase (store, params) {
export function sellItems (store, params) {
const user = store.state.user.data;
sellOp(user, {params, query: {amount: params.amount}});
axios
.post(`/api/v3/user/sell/${params.type}/${params.key}?amount=${params.amount}`);
// TODO
// .then((res) => console.log('equip', res))
// .catch((err) => console.error('equip', err));
axios.post(`/api/v3/user/sell/${params.type}/${params.key}?amount=${params.amount}`);
}

View File

@@ -55,6 +55,8 @@ export default function () {
actions,
getters,
state: {
serverAppVersion: '',
deniedUpdate: false,
title: 'Habitica',
isUserLoggedIn,
isUserLoaded: false, // Means the user and the user's tasks are ready
@@ -134,7 +136,6 @@ export default function () {
modalStack: [],
brokenChallengeTask: {},
equipmentDrawerOpen: true,
recentlyPurchased: {},
groupPlans: [],
groupNotifications: [],
},

View File

@@ -280,5 +280,7 @@
"emptyMessagesLine1": "You don't have any messages",
"emptyMessagesLine2": "Send a message to start a conversation!",
"letsgo": "Let's Go!",
"selected": "Selected"
"selected": "Selected",
"howManyToBuy": "How many would you like to buy?",
"habiticaHasUpdated": "There is a new version of Habitica. Would you like to refresh to get the latest updates?"
}

View File

@@ -72,7 +72,9 @@
"APIv3": "API v3",
"APIText": "Copy these for use in third party applications. However, think of your API Token like a password, and do not share it publicly. You may occasionally be asked for your User ID, but never post your API Token where others can see it, including on Github.",
"APIToken": "API Token (this is a password - see warning above!)",
"APITokenWarning": "If you need a new API Token (e.g., if you accidentally shared it), email <%= hrefTechAssistanceEmail %> with your User ID and current Token. Once it is reset you will need to re-authorize everything by logging out of the website and mobile app and by providing the new Token to any other Habitica tools that you use.",
"showAPIToken": "Show API Token",
"hideAPIToken": "Hide API Token",
"APITokenWarning": "If you need a new API Token (e.g., if you accidentally shared it), email <%= hrefTechAssistanceEmail %> with your User ID and current Token. Once it is reset you will need to re-authorize everything by logging out of the website and mobile app and by providing the new Token to any other Habitica tools that you use.",
"thirdPartyApps": "Third Party Apps",
"dataToolDesc": "A webpage that shows you certain information from your Habitica account, such as statistics about your tasks, equipment, and skills.",
"beeminder": "Beeminder",

View File

@@ -204,5 +204,6 @@
"subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to <a href='/user/settings/subscription'>User icon &gt; Settings &gt; Subscription</a>.",
"purchaseAll": "Purchase All",
"gemsPurchaseNote": "Subscribers can buy gems for gold in the Market! For easy access, you can also pin the gem to your Rewards column.",
"gemsRemaining": "gems remaining"
"gemsRemaining": "gems remaining",
"notEnoughGemsToBuy": "You are unable to buy that amount of gems"
}

View File

@@ -6,18 +6,45 @@ import {
import buyHealthPotion from './buyHealthPotion';
import buyArmoire from './buyArmoire';
import buyGear from './buyGear';
import buyMysterySet from './buyMysterySet';
import buyQuest from './buyQuest';
import buySpecialSpell from './buySpecialSpell';
// @TODO: remove the req option style. Dependency on express structure is an anti-pattern
// We should either have more parms or a set structure validated by a Type checker
// @TODO: when we are sure buy is the only function used, let's move the buy files to a folder
module.exports = function buy (user, req = {}, analytics) {
let key = get(req, 'params.key');
if (!key) throw new BadRequest(i18n.t('missingKeyParam', req.language));
// @TODO: Slowly remove the need for key and use type instead
// This should evenutally be the 'factory' function with vendor classes
let type = get(req, 'type');
if (!type) type = key;
// @TODO: For now, builk purchasing is here, but we should probably have a parent vendor
// class that calls the factory and handles larger operations. If there is more than just bulk
let quantity = 1;
if (req.quantity) quantity = req.quantity;
let buyRes;
if (key === 'potion') {
buyRes = buyHealthPotion(user, req, analytics);
} else if (key === 'armoire') {
buyRes = buyArmoire(user, req, analytics);
} else {
buyRes = buyGear(user, req, analytics);
for (let i = 0; i < quantity; i += 1) {
if (type === 'potion') {
buyRes = buyHealthPotion(user, req, analytics);
} else if (type === 'armoire') {
buyRes = buyArmoire(user, req, analytics);
} else if (type === 'mystery') {
buyRes = buyMysterySet(user, req, analytics);
} else if (type === 'quest') {
buyRes = buyQuest(user, req, analytics);
} else if (type === 'special') {
buyRes = buySpecialSpell(user, req, analytics);
} else {
buyRes = buyGear(user, req, analytics);
}
}
return buyRes;

View File

@@ -14,68 +14,52 @@ import {
import { removeItemByPath } from './pinnedGearUtils';
import getItemInfo from '../libs/getItemInfo';
module.exports = function purchase (user, req = {}, analytics) {
let type = get(req.params, 'type');
let key = get(req.params, 'key');
function buyGems (user, analytics, req, key) {
let convRate = planGemLimits.convRate;
let convCap = planGemLimits.convCap;
convCap += user.purchased.plan.consecutive.gemCapExtra;
// Some groups limit their members ability to obtain gems
// The check is async so it's done on the server (in server/controllers/api-v3/user#purchase)
// only and not on the client,
// resulting in a purchase that will seem successful until the request hit the server.
if (!user.purchased || !user.purchased.plan || !user.purchased.plan.customerId) {
throw new NotAuthorized(i18n.t('mustSubscribeToPurchaseGems', req.language));
}
if (user.stats.gp < convRate) {
throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));
}
if (user.purchased.plan.gemsBought >= convCap) {
throw new NotAuthorized(i18n.t('reachedGoldToGemCap', {convCap}, req.language));
}
user.balance += 0.25;
user.purchased.plan.gemsBought++;
user.stats.gp -= convRate;
if (analytics) {
analytics.track('purchase gems', {
uuid: user._id,
itemKey: key,
acquireMethod: 'Gold',
goldCost: convRate,
category: 'behavior',
headers: req.headers,
});
}
return [
pick(user, splitWhitespace('stats balance')),
i18n.t('plusOneGem', req.language),
];
}
function getItemAndPrice (user, type, key, req) {
let item;
let price;
if (!type) {
throw new BadRequest(i18n.t('typeRequired', req.language));
}
if (!key) {
throw new BadRequest(i18n.t('keyRequired', req.language));
}
if (type === 'gems' && key === 'gem') {
let convRate = planGemLimits.convRate;
let convCap = planGemLimits.convCap;
convCap += user.purchased.plan.consecutive.gemCapExtra;
// Some groups limit their members ability to obtain gems
// The check is async so it's done on the server (in server/controllers/api-v3/user#purchase)
// only and not on the client,
// resulting in a purchase that will seem successful until the request hit the server.
if (!user.purchased || !user.purchased.plan || !user.purchased.plan.customerId) {
throw new NotAuthorized(i18n.t('mustSubscribeToPurchaseGems', req.language));
}
if (user.stats.gp < convRate) {
throw new NotAuthorized(i18n.t('messageNotEnoughGold', req.language));
}
if (user.purchased.plan.gemsBought >= convCap) {
throw new NotAuthorized(i18n.t('reachedGoldToGemCap', {convCap}, req.language));
}
user.balance += 0.25;
user.purchased.plan.gemsBought++;
user.stats.gp -= convRate;
if (analytics) {
analytics.track('purchase gems', {
uuid: user._id,
itemKey: key,
acquireMethod: 'Gold',
goldCost: convRate,
category: 'behavior',
headers: req.headers,
});
}
return [
pick(user, splitWhitespace('stats balance')),
i18n.t('plusOneGem', req.language),
];
}
let acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear', 'bundles'];
if (acceptedTypes.indexOf(type) === -1) {
throw new NotFound(i18n.t('notAccteptedType', req.language));
}
if (type === 'gear') {
item = content.gear.flat[key];
@@ -98,17 +82,10 @@ module.exports = function purchase (user, req = {}, analytics) {
price = item.value / 4;
}
if (!item.canBuy(user)) {
throw new NotAuthorized(i18n.t('messageNotAvailable', req.language));
}
if (!user.balance || user.balance < price) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
let itemInfo = getItemInfo(user, type, item);
removeItemByPath(user, itemInfo.path);
return {item, price};
}
function purchaseItem (user, item, price, type, key) {
user.balance -= price;
if (type === 'gear') {
@@ -127,6 +104,50 @@ module.exports = function purchase (user, req = {}, analytics) {
}
user.items[type][key]++;
}
}
module.exports = function purchase (user, req = {}, analytics) {
let type = get(req.params, 'type');
let key = get(req.params, 'key');
let quantity = req.quantity || 1;
if (!type) {
throw new BadRequest(i18n.t('typeRequired', req.language));
}
if (!key) {
throw new BadRequest(i18n.t('keyRequired', req.language));
}
if (type === 'gems' && key === 'gem') {
let gemResponse;
for (let i = 0; i < quantity; i += 1) {
gemResponse = buyGems(user, analytics, req, key);
}
return gemResponse;
}
let acceptedTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'gear', 'bundles'];
if (acceptedTypes.indexOf(type) === -1) {
throw new NotFound(i18n.t('notAccteptedType', req.language));
}
let {price, item} = getItemAndPrice(user, type, key, req);
if (!item.canBuy(user)) {
throw new NotAuthorized(i18n.t('messageNotAvailable', req.language));
}
if (!user.balance || user.balance < price) {
throw new NotAuthorized(i18n.t('notEnoughGems', req.language));
}
let itemInfo = getItemInfo(user, type, item);
removeItemByPath(user, itemInfo.path);
for (let i = 0; i < quantity; i += 1) {
purchaseItem(user, item, price, type, key);
}
if (analytics) {
analytics.track('acquire item', {

View File

@@ -1,9 +1,12 @@
import buySpecialSpellOp from './buySpecialSpell';
import buy from './buy';
import purchaseOp from './purchase';
import get from 'lodash/get';
module.exports = function purchaseWithSpell (user, req = {}, analytics) {
const type = get(req.params, 'type');
return type === 'spells' ? buySpecialSpellOp(user, req) : purchaseOp(user, req, analytics);
// Set up type for buy function - different than the above type.
req.type = 'special';
return type === 'spells' ? buy(user, req, analytics) : purchaseOp(user, req, analytics);
};

View File

@@ -874,11 +874,21 @@ api.buy = {
let buyRes;
let specialKeys = ['snowball', 'spookySparkles', 'shinySeed', 'seafoam'];
// @TODO: Remove this when mobile passes type in body
let type = req.params.key;
if (specialKeys.indexOf(req.params.key) !== -1) {
buyRes = common.ops.buySpecialSpell(user, req);
} else {
buyRes = common.ops.buy(user, req, res.analytics);
type = 'special';
}
req.type = type;
// @TODO: right now common follow express structure, but we should decouple the dependency
if (req.body.type) req.type = req.body.type;
let quantity = 1;
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
buyRes = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyRes);
@@ -926,7 +936,7 @@ api.buyGear = {
url: '/user/buy-gear/:key',
async handler (req, res) {
let user = res.locals.user;
let buyGearRes = common.ops.buyGear(user, req, res.analytics);
let buyGearRes = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyGearRes);
},
@@ -966,7 +976,9 @@ api.buyArmoire = {
url: '/user/buy-armoire',
async handler (req, res) {
let user = res.locals.user;
let buyArmoireResponse = common.ops.buyArmoire(user, req, res.analytics);
req.type = 'armoire';
req.params.key = 'armoire';
let buyArmoireResponse = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyArmoireResponse);
},
@@ -1004,7 +1016,9 @@ api.buyHealthPotion = {
url: '/user/buy-health-potion',
async handler (req, res) {
let user = res.locals.user;
let buyHealthPotionResponse = common.ops.buyHealthPotion(user, req, res.analytics);
req.type = 'potion';
req.params.key = 'potion';
let buyHealthPotionResponse = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyHealthPotionResponse);
},
@@ -1044,7 +1058,8 @@ api.buyMysterySet = {
url: '/user/buy-mystery-set/:key',
async handler (req, res) {
let user = res.locals.user;
let buyMysterySetRes = common.ops.buyMysterySet(user, req, res.analytics);
req.type = 'mystery';
let buyMysterySetRes = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyMysterySetRes);
},
@@ -1084,7 +1099,8 @@ api.buyQuest = {
url: '/user/buy-quest/:key',
async handler (req, res) {
let user = res.locals.user;
let buyQuestRes = common.ops.buyQuest(user, req, res.analytics);
req.type = 'quest';
let buyQuestRes = common.ops.buy(user, req, res.analytics);
await user.save();
res.respond(200, ...buyQuestRes);
},
@@ -1123,7 +1139,8 @@ api.buySpecialSpell = {
url: '/user/buy-special-spell/:key',
async handler (req, res) {
let user = res.locals.user;
let buySpecialSpellRes = common.ops.buySpecialSpell(user, req);
req.type = 'special';
let buySpecialSpellRes = common.ops.buy(user, req);
await user.save();
res.respond(200, ...buySpecialSpellRes);
},
@@ -1337,6 +1354,11 @@ api.purchase = {
if (!canGetGems) throw new NotAuthorized(res.t('groupPolicyCannotGetGems'));
}
// Req is currently used as options. Slighly confusing, but this will solve that for now.
let quantity = 1;
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
let purchaseRes = common.ops.purchaseWithSpell(user, req, res.analytics);
await user.save();
res.respond(200, ...purchaseRes);

View File

@@ -1,3 +1,5 @@
import packageInfo from '../../../package.json';
module.exports = function responseHandler (req, res, next) {
// Only used for successful responses
res.respond = function respond (status = 200, data = {}, message) {
@@ -15,6 +17,8 @@ module.exports = function responseHandler (req, res, next) {
response.userV = user._v;
}
response.appVersion = packageInfo.version;
res.status(status).json(response);
};