diff --git a/test/api/v3/integration/user/POST-user_purchase.test.js b/test/api/v3/integration/user/POST-user_purchase.test.js index 7db7600f60..b1d3749256 100644 --- a/test/api/v3/integration/user/POST-user_purchase.test.js +++ b/test/api/v3/integration/user/POST-user_purchase.test.js @@ -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); + }); + }); }); diff --git a/test/api/v3/integration/user/POST-user_buy.test.js b/test/api/v3/integration/user/buy/POST-user_buy.test.js similarity index 79% rename from test/api/v3/integration/user/POST-user_buy.test.js rename to test/api/v3/integration/user/buy/POST-user_buy.test.js index af40621b49..37670d115b 100644 --- a/test/api/v3/integration/user/POST-user_buy.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy.test.js @@ -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()})); + }); }); diff --git a/test/api/v3/integration/user/POST-user_buy_armoire.test.js b/test/api/v3/integration/user/buy/POST-user_buy_armoire.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user_buy_armoire.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_armoire.test.js index 32b8134647..f3f40e72b6 100644 --- a/test/api/v3/integration/user/POST-user_buy_armoire.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_armoire.test.js @@ -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; diff --git a/test/api/v3/integration/user/POST-user_buy_gear.test.js b/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js similarity index 93% rename from test/api/v3/integration/user/POST-user_buy_gear.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_gear.test.js index 5f07886ce4..6056e2fbdf 100644 --- a/test/api/v3/integration/user/POST-user_buy_gear.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_gear.test.js @@ -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; diff --git a/test/api/v3/integration/user/POST-user_buy_health_potion.test.js b/test/api/v3/integration/user/buy/POST-user_buy_health_potion.test.js similarity index 89% rename from test/api/v3/integration/user/POST-user_buy_health_potion.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_health_potion.test.js index 8e29964997..437d0e6a39 100644 --- a/test/api/v3/integration/user/POST-user_buy_health_potion.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_health_potion.test.js @@ -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; diff --git a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js b/test/api/v3/integration/user/buy/POST-user_buy_mystery_set.test.js similarity index 94% rename from test/api/v3/integration/user/POST-user_buy_mystery_set.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_mystery_set.test.js index da7116d732..bd49fae5a5 100644 --- a/test/api/v3/integration/user/POST-user_buy_mystery_set.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_mystery_set.test.js @@ -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; diff --git a/test/api/v3/integration/user/POST-user_buy_quest.test.js b/test/api/v3/integration/user/buy/POST-user_buy_quest.test.js similarity index 88% rename from test/api/v3/integration/user/POST-user_buy_quest.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_quest.test.js index 17a75b5ec7..f539a29a5a 100644 --- a/test/api/v3/integration/user/POST-user_buy_quest.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_quest.test.js @@ -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; diff --git a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js b/test/api/v3/integration/user/buy/POST-user_buy_special_spell.test.js similarity index 90% rename from test/api/v3/integration/user/POST-user_buy_special_spell.test.js rename to test/api/v3/integration/user/buy/POST-user_buy_special_spell.test.js index f6cc20b8d9..50984d0cb0 100644 --- a/test/api/v3/integration/user/POST-user_buy_special_spell.test.js +++ b/test/api/v3/integration/user/buy/POST-user_buy_special_spell.test.js @@ -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; diff --git a/test/common/ops/buy.js b/test/common/ops/buy.js deleted file mode 100644 index 52d5917132..0000000000 --- a/test/common/ops/buy.js +++ /dev/null @@ -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, - }); - }); -}); diff --git a/test/common/ops/buy/buy.js b/test/common/ops/buy/buy.js new file mode 100644 index 0000000000..20f849b748 --- /dev/null +++ b/test/common/ops/buy/buy.js @@ -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); + }); +}); diff --git a/test/common/ops/buyArmoire.js b/test/common/ops/buy/buyArmoire.js similarity index 89% rename from test/common/ops/buyArmoire.js rename to test/common/ops/buy/buyArmoire.js index facf87132c..8d0c529995 100644 --- a/test/common/ops/buyArmoire.js +++ b/test/common/ops/buy/buyArmoire.js @@ -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 = {}; diff --git a/test/common/ops/buyGear.js b/test/common/ops/buy/buyGear.js similarity index 93% rename from test/common/ops/buyGear.js rename to test/common/ops/buy/buyGear.js index 8e19a90d49..afc881f6a7 100644 --- a/test/common/ops/buyGear.js +++ b/test/common/ops/buy/buyGear.js @@ -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; diff --git a/test/common/ops/buyHealthPotion.js b/test/common/ops/buy/buyHealthPotion.js similarity index 91% rename from test/common/ops/buyHealthPotion.js rename to test/common/ops/buy/buyHealthPotion.js index e79a063093..e0d49678b7 100644 --- a/test/common/ops/buyHealthPotion.js +++ b/test/common/ops/buy/buyHealthPotion.js @@ -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; diff --git a/test/common/ops/buyMysterySet.js b/test/common/ops/buy/buyMysterySet.js similarity index 90% rename from test/common/ops/buyMysterySet.js rename to test/common/ops/buy/buyMysterySet.js index 600ad99b22..9524de3a03 100644 --- a/test/common/ops/buyMysterySet.js +++ b/test/common/ops/buy/buyMysterySet.js @@ -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; diff --git a/test/common/ops/buyQuest.js b/test/common/ops/buy/buyQuest.js similarity index 88% rename from test/common/ops/buyQuest.js rename to test/common/ops/buy/buyQuest.js index 4bcd4c5eef..3c67abc4e5 100644 --- a/test/common/ops/buyQuest.js +++ b/test/common/ops/buy/buyQuest.js @@ -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; diff --git a/test/common/ops/buySpecialSpell.js b/test/common/ops/buy/buySpecialSpell.js similarity index 84% rename from test/common/ops/buySpecialSpell.js rename to test/common/ops/buy/buySpecialSpell.js index 5a24426f81..70709fe46b 100644 --- a/test/common/ops/buySpecialSpell.js +++ b/test/common/ops/buy/buySpecialSpell.js @@ -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; diff --git a/test/common/ops/purchase.js b/test/common/ops/purchase.js index 50caf7ba10..d1b78a3460 100644 --- a/test/common/ops/purchase.js +++ b/test/common/ops/purchase.js @@ -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); + }); + }); }); diff --git a/website/client/components/shops/buyModal.vue b/website/client/components/shops/buyModal.vue index 95af87ad66..5a865c7917 100644 --- a/website/client/components/shops/buyModal.vue +++ b/website/client/components/shops/buyModal.vue @@ -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 @@ diff --git a/website/client/mixins/buy.js b/website/client/mixins/buy.js index 6aff407e1d..23d3ecb142 100644 --- a/website/client/mixins/buy.js +++ b/website/client/mixins/buy.js @@ -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') { diff --git a/website/client/store/actions/shops.js b/website/client/store/actions/shops.js index 010d948729..ac668ff13b 100644 --- a/website/client/store/actions/shops.js +++ b/website/client/store/actions/shops.js @@ -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}`); } diff --git a/website/client/store/index.js b/website/client/store/index.js index 7f7bb62ffb..a25f464c30 100644 --- a/website/client/store/index.js +++ b/website/client/store/index.js @@ -136,7 +136,6 @@ export default function () { modalStack: [], brokenChallengeTask: {}, equipmentDrawerOpen: true, - recentlyPurchased: {}, groupPlans: [], groupNotifications: [], }, diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index cf1214a92a..6cbd6c9679 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -281,5 +281,6 @@ "emptyMessagesLine2": "Send a message to start a conversation!", "letsgo": "Let's Go!", "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?" } diff --git a/website/common/locales/en/subscriber.json b/website/common/locales/en/subscriber.json index 10f0053845..bc9448b671 100644 --- a/website/common/locales/en/subscriber.json +++ b/website/common/locales/en/subscriber.json @@ -204,5 +204,6 @@ "subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to User icon > Settings > Subscription.", "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" } diff --git a/website/common/script/ops/buy.js b/website/common/script/ops/buy.js index d23a587504..aee81ecf7b 100644 --- a/website/common/script/ops/buy.js +++ b/website/common/script/ops/buy.js @@ -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; diff --git a/website/common/script/ops/purchase.js b/website/common/script/ops/purchase.js index c02346c4c9..7cb74c804e 100644 --- a/website/common/script/ops/purchase.js +++ b/website/common/script/ops/purchase.js @@ -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', { diff --git a/website/common/script/ops/purchaseWithSpell.js b/website/common/script/ops/purchaseWithSpell.js index 3c4c71dc9a..1e0872afaa 100644 --- a/website/common/script/ops/purchaseWithSpell.js +++ b/website/common/script/ops/purchaseWithSpell.js @@ -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); }; diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 1af401100c..e8e89cb47c 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -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);