Compare commits

..

14 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
Sabe Jones
6a550b34df 4.4.5 2017-10-21 03:35:51 +00:00
SabreCat
0c973b1cf0 chore(news): Bailey 2017-10-21 03:33:42 +00:00
SabreCat
4170ef5e79 Merge branch 'release' into develop 2017-10-21 03:23:13 +00:00
Keith Holliday
506275c30e Oct 19 fixes (#9234)
* Add more checks for user achievements

* Began to add ajax request for bailey

* Prevented purchase of locked item

* Refactored notifications to have unique id and use actions

* Added feature banner when gear is bought

* Removed debug code

* Mark group notifications as read

* Fixed lint issues

* Added gem icon to purchase all
2017-10-20 15:22:13 +02:00
Alys
6838b7d0a6 remove duplicated word "caused" from Masterclassers quest text 2017-10-20 23:12:26 +10:00
59 changed files with 871 additions and 431 deletions

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "habitica",
"version": "4.4.4",
"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.4",
"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

@@ -172,7 +172,7 @@ export default {
return Promise.resolve(error);
}
this.$store.state.notificationStore.push({
this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: error.response.data.message,
type: 'error',
@@ -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

@@ -1,18 +1,6 @@
.promo_bundle_witchyFamiliars {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -142px 0px;
width: 141px;
height: 441px;
}
.promo_fall_customizations {
.scene_positivity {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: 0px 0px;
width: 141px;
height: 588px;
}
.scene_raking_leaves {
background-image: url(/static/sprites/spritesmith-largeSprites-0.png);
background-position: -284px 0px;
width: 246px;
height: 198px;
width: 531px;
height: 243px;
}

View File

@@ -22,6 +22,7 @@
</style>
<script>
// import axios from 'axios';
import bModal from 'bootstrap-vue/lib/components/modal';
import { mapState } from 'client/libs/store';
import markdown from 'client/directives/markdown';
@@ -38,6 +39,16 @@
directives: {
markdown,
},
mounted () {
this.$root.$on('show::modal', async (modalId) => {
if (modalId !== 'new-stuff') return;
// Request the lastest news, but not locally incase they don't refresh
// let response = await axios.get('/static/new-stuff');
});
},
destroyed () {
this.$root.$off('show::modal');
},
methods: {
close () {
this.$root.$emit('hide::modal', 'new-stuff');

View File

@@ -58,8 +58,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='item.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("shirt", specialShirtKeys)', @click='unlock(`shirt.${specialShirtKeys.join(",shirt.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!userOwnsSet("shirt", specialShirtKeys)')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`shirt.${specialShirtKeys.join(",shirt.")}`)') {{ $t('purchaseAll') }}
#skin.section.customize-section(v-if='activeTopPage === "skin"')
.row.sub-menu.col-6.offset-3.text-center
.col-6.offset-3.text-center.sub-menu-item(:class='{active: activeSubPage === "color"}')
@@ -77,18 +80,18 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!hideSet(set) && !userOwnsSet("skin", set.keys)', @click='unlock(`skin.${set.keys.join(",skin.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!hideSet(set) && !userOwnsSet("skin", set.keys)')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
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')}}
@@ -104,8 +107,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!hideSet(set) && !userOwnsSet("hair", set.keys, "color")', @click='unlock(`hair.color.${set.keys.join(",hair.color.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!hideSet(set) && !userOwnsSet("hair", set.keys, "color")')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.color.${set.keys.join(",hair.color.")}`)') {{ $t('purchaseAll') }}
#style.row(v-if='activeSubPage === "style"')
.col-12.customize-options(v-if='editing')
.head_0.option(@click='set({"preferences.hair.base": 0})', :class="[{ active: user.preferences.hair.base === 0 }, 'hair_base_0_' + user.preferences.hair.color]")
@@ -115,8 +121,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("hair", baseHair3Keys, "base")', @click='unlock(`hair.base.${baseHair3Keys.join(",hair.base.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!userOwnsSet("hair", baseHair3Keys, "base")')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.base.${baseHair3Keys.join(",hair.base.")}`)') {{ $t('purchaseAll') }}
.col-12.customize-options(v-if='editing')
.option(v-for='option in baseHair4',
:class='{active: option.active, locked: option.locked}')
@@ -124,16 +133,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("hair", baseHair4Keys, "base")', @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.text-center(v-if='!userOwnsSet("hair", baseHair4Keys, "base")')
.gem-lock
.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') }}
.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',
@@ -146,8 +150,18 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("hair", baseHair2Keys, "base")', @click='unlock(`hair.base.${baseHair2Keys.join(",hair.base.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!userOwnsSet("hair", baseHair2Keys, "base")')
.gem-lock
.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]")
@@ -157,8 +171,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("hair", baseHair5Keys, "beard")', @click='unlock(`hair.beard.${baseHair5Keys.join(",hair.beard.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!userOwnsSet("hair", baseHair5Keys, "beard")')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.beard.${baseHair5Keys.join(",hair.beard.")}`)') {{ $t('purchaseAll') }}
.col-12.customize-options(v-if='editing')
.option(v-for='option in baseHair6',
:class='{active: option.active, locked: option.locked}')
@@ -166,8 +183,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!userOwnsSet("hair", baseHair6Keys, "mustache")', @click='unlock(`hair.mustache.${baseHair6Keys.join(",hair.mustache.")}`)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!userOwnsSet("hair", baseHair6Keys, "mustache")')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(`hair.mustache.${baseHair6Keys.join(",hair.mustache.")}`)') {{ $t('purchaseAll') }}
#extra.section.container.customize-section(v-if='activeTopPage === "extra"')
.row.sub-menu
.col-3.offset-1.text-center.sub-menu-item(@click='changeSubPage("glasses")', :class='{active: activeSubPage === "glasses"}')
@@ -191,8 +211,11 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.gem-lock(v-if='option.locked')
.svg-icon.gem(v-html='icons.gem')
span 2
.col-12.text-center
button.btn.btn-secondary.purchase-all(v-if='!animalEarsOwned', @click='unlock(animalEarsUnlockString)') {{ $t('purchaseAll') }}
.col-12.text-center(v-if='!animalEarsOwned')
.gem-lock
.svg-icon.gem(v-html='icons.gem')
span 5
button.btn.btn-secondary.purchase-all(@click='unlock(animalEarsUnlockString)') {{ $t('purchaseAll') }}
#wheelchairs.row(v-if='activeSubPage === "wheelchair"')
.col-12.customize-options
.option(@click='set({"preferences.chair": "none"})', :class='{active: user.preferences.chair === "none"}')
@@ -531,28 +554,35 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
margin-bottom: .5em;
margin-right: .5em;
.gem-lock {
.svg-icon {
width: 16px;
}
span {
color: #24cc8f;
font-weight: bold;
margin-left: .5em;
}
.svg-icon, span {
display: inline-block;
vertical-align: bottom;
}
}
.sprite.customize-option {
margin: 0 auto;
}
}
.text-center .gem-lock {
display: inline-block;
margin-right: 1em;
margin-bottom: 1.6em;
vertical-align: bottom;
}
.gem-lock {
.svg-icon {
width: 16px;
}
span {
color: #24cc8f;
font-weight: bold;
margin-left: .5em;
}
.svg-icon, span {
display: inline-block;
vertical-align: bottom;
}
}
.option.active {
border: 4px solid $purple-200;
border-radius: 4px;

View File

@@ -42,16 +42,11 @@ div.item-with-icon.item-notifications.dropdown
span {{message.name}}
span.clear-button(@click='clearMessages(message.key)', :popover="$t('clear')",
popover-placement='right', popover-trigger='mouseenter', popover-append-to-body='true') Clear
a.dropdown-item(v-for='(notification, index) in user.groupNotifications', @click='viewGroupApprovalNotification(notification, index, true)')
a.dropdown-item(v-for='notification in groupNotifications', :key='notification.id')
span(:class="groupApprovalNotificationIcon(notification)")
span
| {{notification.data.message}}
a.dropdown-item(@click='viewGroupApprovalNotification(notification, $index)',
:popover="$t('clear')",
popover-placement='right',
popover-trigger='mouseenter',
popover-append-to-body='true')
span.glyphicon.glyphicon-remove-circle
span {{notification.data.message}}
span.clear-button(@click='viewGroupApprovalNotification(notification)', :popover="$t('clear')",
popover-placement='right', popover-trigger='mouseenter', popover-append-to-body='true') Clear
</template>
<style lang='scss' scoped>
@@ -159,6 +154,7 @@ div.item-with-icon.item-notifications.dropdown
</style>
<script>
import axios from 'axios';
import isEmpty from 'lodash/isEmpty';
import map from 'lodash/map';
@@ -194,6 +190,9 @@ export default {
}
return userNewMessages;
},
groupNotifications () {
return this.$store.state.groupNotifications;
},
notificationsCount () {
let count = 0;
@@ -217,6 +216,8 @@ export default {
count += Object.keys(this.userNewMessages).length;
}
count += this.groupNotifications.length;
return count;
},
},
@@ -236,8 +237,8 @@ export default {
return unallocatedValue;
} else if (!isEmpty(user.newMessages)) {
return messageValue;
} else if (!isEmpty(user.groupNotifications)) {
let groupNotificationTypes = map(user.groupNotifications, 'type');
} else if (!isEmpty(this.groupNotifications)) {
let groupNotificationTypes = map(this.groupNotifications, 'type');
if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVAL') !== -1) {
return groupApprovalRequested;
} else if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVED') !== -1) {
@@ -303,11 +304,14 @@ export default {
hasNoNotifications () {
return this.selectNotificationValue(false, false, false, false, false, true, false, false);
},
viewGroupApprovalNotification (notification, index, navigate) {
// @TODO: USe notifications: User.readNotification(notification.id);
this.user.groupNotifications.splice(index, 1);
return navigate; // @TODO: remove
// @TODO: this.$router.go if (navigate) go('options.social.guilds.detail', {gid: notification.data.groupId});
viewGroupApprovalNotification (notification) {
this.$store.state.groupNotifications = this.groupNotifications.filter(groupNotif => {
return groupNotif.id !== notification.id;
});
axios.post('/api/v3/notifications/read', {
notificationIds: [notification.id],
});
},
groupApprovalNotificationIcon (notification) {
if (notification.type === 'GROUP_TASK_APPROVAL') {

View File

@@ -276,8 +276,8 @@ export default {
if (after === before || after === false) return;
this.$root.$emit('show::modal', 'armoire-empty');
},
questCompleted (after) {
if (!after) return;
questCompleted () {
if (!this.questCompleted) return;
this.$root.$emit('show::modal', 'quest-completed');
},
invitedToQuest (after) {
@@ -291,6 +291,26 @@ export default {
this.$store.dispatch('user:fetch'),
this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => {
this.checkUserAchievements();
// @TODO: This is a timeout to ensure dom is loaded
window.setTimeout(() => {
this.initTour();
if (this.user.flags.tour.intro === this.TOUR_END || !this.user.flags.welcomed) return;
this.goto('intro', 0);
}, 2000);
this.runYesterDailies();
// Do not remove the event listener as it's live for the entire app lifetime
document.addEventListener('mousemove', () => this.checkNextCron());
document.addEventListener('touchstart', () => this.checkNextCron());
document.addEventListener('mousedown', () => this.checkNextCron());
document.addEventListener('keydown', () => this.checkNextCron());
});
},
methods: {
checkUserAchievements () {
// List of prompts for user on changes. Sounds like we may need a refactor here, but it is clean for now
if (this.user.flags.newStuff) {
this.$root.$emit('show::modal', 'new-stuff');
@@ -313,24 +333,7 @@ export default {
if (this.userClassSelect) {
this.$root.$emit('show::modal', 'choose-class');
}
// @TODO: This is a timeout to ensure dom is loaded
window.setTimeout(() => {
this.initTour();
if (this.user.flags.tour.intro === this.TOUR_END || !this.user.flags.welcomed) return;
this.goto('intro', 0);
}, 2000);
this.runYesterDailies();
// Do not remove the event listener as it's live for the entire app lifetime
document.addEventListener('mousemove', () => this.checkNextCron());
document.addEventListener('touchstart', () => this.checkNextCron());
document.addEventListener('mousedown', () => this.checkNextCron());
document.addEventListener('keydown', () => this.checkNextCron());
});
},
methods: {
},
showLevelUpNotifications (newlevel) {
this.lvl();
this.playSound('Level_Up');
@@ -417,8 +420,7 @@ export default {
this.scheduleNextCron();
},
transferGroupNotification (notification) {
if (!this.user.groupNotifications) this.user.groupNotifications = [];
this.user.groupNotifications.push(notification);
this.$store.state.groupNotifications.push(notification);
},
async handleUserNotifications (after) {
if (!after || after.length === 0 || !Array.isArray(after)) return;
@@ -426,7 +428,7 @@ export default {
let notificationsToRead = [];
let scoreTaskNotification = [];
this.user.groupNotifications = []; // Flush group notifictions
this.$store.state.groupNotifications = []; // Flush group notifictions
after.forEach((notification) => {
if (this.lastShownNotifications.indexOf(notification.id) !== -1) {
@@ -578,6 +580,8 @@ export default {
}
this.user.notifications = []; // reset the notifications
this.checkUserAchievements();
},
},
};

View File

@@ -2,8 +2,6 @@
b-modal#amazon-payment(title="Amazon", :hide-footer="true", size='md')
h2.text-center Continue with Amazon
#AmazonPayButton
| {{amazonPayments}}
| {{amazonLoggedIn}}
#AmazonPayWallet(v-if="amazonLoggedIn", style="width: 400px; height: 228px;")
#AmazonPayRecurring(v-if="amazonLoggedIn && amazonPayments.type === 'subscription'",
style="width: 400px; height: 140px;")

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

@@ -40,6 +40,10 @@
span.rectangle
span.text(v-once) {{ $t('featuredset', { name: seasonal.featured.text }) }}
span.rectangle
div.featured-label.with-border(v-else)
span.rectangle
span.text(v-once) {{ $t('featuredItems') }}
span.rectangle
div.items.margin-center
shopItem(
@@ -264,6 +268,11 @@
}
</style>
<style scoped>
.margin-center {
margin: 0 auto;
}
</style>
<script>
import {mapState} from 'client/libs/store';
@@ -493,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

@@ -135,9 +135,7 @@ export default {
},
watch: {
show () {
setTimeout(() => {
this.$store.state.notificationStore.splice(0, 1);
}, 1000);
this.$store.dispatch('snackbars:remove', this.notification);
},
},
computed: {

View File

@@ -1,6 +1,6 @@
<template lang="pug">
.notifications
div(v-for='notification in notifications')
div(v-for='notification in notifications', :key='notification.uuid')
notification(:notification='notification')
</template>

View File

@@ -4,23 +4,13 @@
.align-self-center.right-margin(:class='baileyClass')
.media-body
h1.align-self-center(v-markdown='$t("newStuff")')
h2 10/16/2017 - TASK PAGE UPDATES AND FINAL MASTERCLASSER QUEST LINE
h2 10/20/2017 - USE CASE SPOTLIGHT: CULTIVATING POSITIVITY
hr
h3 Requested Updates to Task Page
p(v-markdown='"We\'re happy to announce that we\'ve pushed some of the most-requested updates from our [feedback form](https://docs.google.com/forms/d/e/1FAIpQLScpeCeoTbmNPotBvEWtAFeVlD6g83KEN_YlwD_GHB4yHJbZww/viewform): the return of quick-add tasks above each column, a more compact header and task page footer, and the ability to refresh the page using the new sync button in the upper-right! Thank you very much to everyone who left us thoughtful comments. We\'re still working on implementing some of the advanced options like push to top/bottom, the ability to batch-add tasks, and manual task attribute allocation, but you\'ll be seeing them soon."')
p We know that this has been a disruptive experience for many of you, and we're very sorry about that. This was a much tougher rollout than we anticipated, and we've been so grateful for your patience, your thoughtful feedback, and your supportive messages. Our small team has been working nights and weekends since July to bring you the upgrade and fix the bugs that resulted, but we only have two full-time web developers and a small handful of part-timers, so things don't always move as quickly as we'd like. It's been beyond exhausting, but we are determined to get everything running smoothly again.
p The good news is that a large part of this redesign was rewriting the entirety of the front-end code from scratch, so we've managed to completely get rid of the outdated, cobbled-together code that was preventing us from adding new features or quickly fixing bugs. Being trapped between the old and new code was particularly rough for us, because it meant that all our development was frozen and we were cut off from our awesome open-source contributors. Now that we've launched, we're able to actually move forward without being paralyzed by the constraints of the old site. Our new design has also enabled us to build a foundation for features that people have been requesting from the beginning, like batch-buying items, new Group Plan upgrades, quest enhancements, and more. This has been a tough time of transition, but we can't wait to show you everything that we have planned.
p(v-markdown='"Thank you again for sticking with us -- and if you have feedback about the redesign, please let us know via our [feedback form](https://docs.google.com/forms/d/e/1FAIpQLScpeCeoTbmNPotBvEWtAFeVlD6g83KEN_YlwD_GHB4yHJbZww/viewform)! We\'ve been reading every single comment that you send in and incorporating your requests into our roadmap going forward. We\'re incredibly lucky to have such passionate users -- you guys mean the world to us."')
p Look out for more updates soon!
.media
.media-body
h3 Mystery of the Masterclassers Quest Line
p(v-markdown='"There\'s a new set of gold-purchasable quests available in the [Quest Shop](https://habitica.com/shops/quests): the Mystery of the Masterclassers Quest-Line! After some of Habitica\'s denizens are possessed by mysterious and malevolent objects, the four Masterclassers have come to you for aid in their quest to find and defeat the enigmatic foe behind these dire deeds. Can you help them save Habitica? If you can defeat the most powerful foe in the game so far, you\'ll earn the exclusive Aether Equipment Set... and a rare Mount like none other."')
.quest_lostMasterclasser4.left-margin
p The Mystery of the Masterclassers is the culmination of the story told in the gold-purchasable quests, so you need to complete all four of those previous quest-lines in order to unlock it. None of the gold-purchasable questlines are limited-edition, so you have plenty of time to save up!
.small by SabreCat, Beffymaroo, and Lemoness
.small Writing by Lemoness
.small Art by AnnDeLune, Beffymaroo, Katy133, Tuqjoi, Kiwibot, stefalupagus, and tricksy.fox
h3 Use Case Spotlight on Positivity
p(v-markdown='"This month\'s [Use Case Spotlight](https://habitica.wordpress.com/2017/10/17/use-case-spotlight-cultivating-positivity/) is about Cultivating Positivity! It features a number of great suggestions submitted by Habiticans in the [Use Case Spotlights Guild](https://habitica.com/groups/guild/1d3a10bf-60aa-4806-a38b-82d1084a59e6). We hope it helps any of you who might be looking to build and maintain a more positive outlook!"')
p Plus, we're collecting user submissions for the next spotlight! How do you use Habitica to manage Money Matters? Well be featuring player-submitted examples in Use Case Spotlights on the Habitica Blog next month, so post your suggestions in the Use Case Spotlight Guild now. We look forward to learning more about how you use Habitica to improve your life and get things done!
.small by Beffymaroo
.scene_positivity.center-block
br
</template>

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
@@ -11,11 +16,11 @@
) {{ $t(filter.label) }}
.tasks-list(ref="tasksWrapper")
input.quick-add(
v-if="isUser", :placeholder="quickAddPlaceholder",
v-if="isUser", :placeholder="quickAddPlaceholder",
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(() => {
@@ -455,6 +483,8 @@ export default {
}
},
openBuyDialog (rewardItem) {
if (rewardItem.locked) return;
// Buy armoire and health potions immediately
let itemsToPurchaseImmediately = ['potion', 'armoire'];
if (itemsToPurchaseImmediately.indexOf(rewardItem.key) !== -1) {
@@ -463,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

@@ -65,7 +65,7 @@ export default {
return round(number, nDigits);
},
notify (html, type, icon, sign) {
this.notifications.push({
this.$store.dispatch('snackbars:add', {
title: '',
text: html,
type,

View File

@@ -14,6 +14,7 @@ import * as notifications from './notifications';
import * as tags from './tags';
import * as hall from './hall';
import * as shops from './shops';
import * as snackbars from './snackbars';
// Actions should be named as 'actionName' and can be accessed as 'namespace:actionName'
// Example: fetch in user.js -> 'user:fetch'
@@ -33,6 +34,7 @@ const actions = flattenAndNamespace({
tags,
hall,
shops,
snackbars,
});
export default actions;

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.state.notificationStore.push({
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

@@ -0,0 +1,13 @@
import uuid from 'uuid';
export function add (store, payload) {
let notification = Object.assign({}, payload);
notification.uuid = uuid();
store.state.notificationStore.push(notification);
}
export function remove (store, payload) {
store.state.notificationStore = store.state.notificationStore.filter(notification => {
return notification.uuid !== payload.uuid;
});
}

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,8 +136,8 @@ 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

@@ -623,7 +623,7 @@
"questLostMasterclasser4Text": "The Mystery of the Masterclassers, Part 4: The Lost Masterclasser",
"questLostMasterclasser4Notes": "You surface from the portal, but youre still suspended in a strange, shifting netherworld. “That was bold,” says a cold voice. “I have to admit, I hadnt planned for a direct confrontation yet.” A woman rises from the churning whirlpool of darkness. “Welcome to the Realm of Void.”<br><br>You try to fight back your rising nausea. “Are you Zinnya?” you ask.<br><br>“That old name for a young idealist,” she says, mouth twisting, and the world writhes beneath you. “No. If anything, you should call me the Antizinnya now, given all that I have done and undone.”<br><br>Suddenly, the portal reopens behind you, and as the four Masterclassers burst out, bolting towards you, Antizinnyas eyes flash with hatred. “I see that my pathetic replacements have managed to follow you.”<br><br>You stare. “Replacements?”<br><br>“As the Master Aethermancer, I was the first Masterclasser — the only Masterclasser. These four are a mockery, each possessing only a fragment of what I once had! I commanded every spell and learned every skill. I shaped your very world to my whim — until the traitorous aether itself collapsed under the weight of my talents and my perfectly reasonable expectations. I have been trapped for millennia in this resulting void, recuperating. Imagine my disgust when I learned how my legacy had been corrupted.” She lets out a low, echoing laugh. “My plan was to destroy their domains before destroying them, but I suppose the order is irrelevant.” With a burst of uncanny strength, she charges forward, and the Realm of Void explodes into chaos.",
"questLostMasterclasser4Completion": "Under the onslaught of your final attack, the Lost Masterclasser screams in frustration, her body flickering into translucence. The thrashing void stills around her as she slumps forward, and for a moment, she seems to change, becoming younger, calmer, with an expression of peace upon her face… but then everything melts away with scarcely a whisper, and youre kneeling once more in the desert sand.<br><br>“It seems that we have much to learn about our own history,” King Manta says, staring at the broken ruins. “After the Master Aethermancer grew overwhelmed and lost control of her abilities, the outpouring of void must have leached the life from the entire land. Everything probably became deserts like this.”<br><br>“No wonder the ancients who founded Habitica stressed a balance of productivity and wellness,” the Joyful Reaper murmurs. “Rebuilding their world would have been a daunting task requiring considerable hard work, but they would have wanted to prevent such a catastrophe from happening again.”<br><br>“Oho, look at those formerly possessed items!” says the April Fool. Sure enough, all of them shimmer with a pale, glimmering translucence from the final burst of aether released when you laid Antizinnyas spirit to rest. “What a dazzling effect. I must take notes.”<br><br>“The concentrated remnants of aether in this area probably caused caused these animals to go invisible, too,” says Lady Glaciate, scratching a patch of emptiness behind the ears. You feel an unseen fluffy head nudge your hand, and suspect that youll have to do some explaining at the Stables back home. As you look at the ruins one last time, you spot all that remains of the first Masterclasser: her shimmering cloak. Lifting it onto your shoulders, you head back to Habit City, pondering everything that you have learned.<br><br>",
"questLostMasterclasser4Completion": "Under the onslaught of your final attack, the Lost Masterclasser screams in frustration, her body flickering into translucence. The thrashing void stills around her as she slumps forward, and for a moment, she seems to change, becoming younger, calmer, with an expression of peace upon her face… but then everything melts away with scarcely a whisper, and youre kneeling once more in the desert sand.<br><br>“It seems that we have much to learn about our own history,” King Manta says, staring at the broken ruins. “After the Master Aethermancer grew overwhelmed and lost control of her abilities, the outpouring of void must have leached the life from the entire land. Everything probably became deserts like this.”<br><br>“No wonder the ancients who founded Habitica stressed a balance of productivity and wellness,” the Joyful Reaper murmurs. “Rebuilding their world would have been a daunting task requiring considerable hard work, but they would have wanted to prevent such a catastrophe from happening again.”<br><br>“Oho, look at those formerly possessed items!” says the April Fool. Sure enough, all of them shimmer with a pale, glimmering translucence from the final burst of aether released when you laid Antizinnyas spirit to rest. “What a dazzling effect. I must take notes.”<br><br>“The concentrated remnants of aether in this area probably caused these animals to go invisible, too,” says Lady Glaciate, scratching a patch of emptiness behind the ears. You feel an unseen fluffy head nudge your hand, and suspect that youll have to do some explaining at the Stables back home. As you look at the ruins one last time, you spot all that remains of the first Masterclasser: her shimmering cloak. Lifting it onto your shoulders, you head back to Habit City, pondering everything that you have learned.<br><br>",
"questLostMasterclasser4Boss": "Anti'zinnya",
"questLostMasterclasser4RageTitle": "Siphoning Void",
"questLostMasterclasser4RageDescription": "Siphoning Void: This bar fills when you don't complete your Dailies. When it is full, Anti'zinnya will remove the party's Mana!",

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);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

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);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 13 KiB