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