Fall Festival Gem Promo (#138)

* content: add gems blocks

* gemsBlocks: include ios and android identifiers

* wip: promo code

* split common constants into multiple files

* add second promo part

* geCurrentEvent, refactor promo

* fix lint

* fix exports, use world state api

* start adding world state tests

* remove console.log

* use gems block for purchases

* remove comments

* fix most unit tests

* restore comment

* fix lint

* prevent apple/google gift tests from breaking other tests when stub is not reset

* fix unit tests, clarify tests names

* iap: use gift object when gifting gems

* allow gift object with less data

* fix iap tests, remove findById stubs

* iap: require less data from the mobile apps

* apply discounts

* add missing worldState file

* fix lint

* add test event

* start removing 20 gems option for web

* start adding support for all gems packages on web

* fix unit tests for apple, stripe and google

* amazon: support all gems blocks

* paypal: support all gems blocks

* fix payments unit tests, add tests for getGemsBlock

* web: add gems plans with discounts, update stripe

* fix amazon and paypal clients, payments success modals

* amazon pay: disabled state

* update icons, start abstracting payments buttons

* begin redesign

* redesign gems modal

* fix buttons

* fix hover color for gems modal close icon

* add key to world state current event

* extend test event length

* implement gems modals designs

* early test fall2020

* fix header banner position

* add missing files

* use iso 8601 for dates, minor ui fixes

* fix time zones

* events: fix ISO8601 format

* fix css indentation

* start abstracting banners

* refactor payments buttons

* test spooky, fix group plans box

* implement gems promo banners, refactor banners, fixes

* fix lint

* fix dates

* remove unused i18n strings

* fix stripe integration test

* fix world state integration tests

* the current active event

* add missing unit tests

* add storybook story for payments buttons component

* fix typo

* fix(stripe): correct label when gifting subscriptions
This commit is contained in:
Matteo Pagliazzi
2020-09-21 16:22:13 +02:00
committed by GitHub
parent 82e6d544c8
commit 83aca20ce5
95 changed files with 1776 additions and 1099 deletions

View File

@@ -2,13 +2,14 @@ import { model as User } from '../../../../../../website/server/models/user';
import amzLib from '../../../../../../website/server/libs/payments/amazon';
import payments from '../../../../../../website/server/libs/payments/payments';
import common from '../../../../../../website/common';
import apiError from '../../../../../../website/server/libs/apiError';
const { i18n } = common;
describe('Amazon Payments - Checkout', () => {
const subKey = 'basic_3mo';
let user; let orderReferenceId; let
headers;
headers; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey];
let setOrderReferenceDetailsSpy;
let confirmOrderReferenceSpy;
let authorizeSpy;
@@ -16,7 +17,7 @@ describe('Amazon Payments - Checkout', () => {
let paymentBuyGemsStub;
let paymentCreateSubscritionStub;
let amount = 5;
let amount = gemsBlock.price / 100;
function expectOrderReferenceSpy () {
expect(setOrderReferenceDetailsSpy).to.be.calledOnce;
@@ -107,13 +108,20 @@ describe('Amazon Payments - Checkout', () => {
paymentMethod,
headers,
};
if (gift) expectedArgs.gift = gift;
if (gift) {
expectedArgs.gift = gift;
expectedArgs.gemsBlock = undefined;
} else {
expectedArgs.gemsBlock = gemsBlock;
}
expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs);
}
it('should purchase gems', async () => {
sinon.stub(user, 'canGetGems').resolves(true);
await amzLib.checkout({ user, orderReferenceId, headers });
await amzLib.checkout({
user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
});
expectBuyGemsStub(amzLib.constants.PAYMENT_METHOD);
expectAmazonStubs();
@@ -144,7 +152,9 @@ describe('Amazon Payments - Checkout', () => {
it('should error if user cannot get gems gems', async () => {
sinon.stub(user, 'canGetGems').resolves(false);
await expect(amzLib.checkout({ user, orderReferenceId, headers }))
await expect(amzLib.checkout({
user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
}))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
message: i18n.t('groupPolicyCannotGetGems'),
@@ -153,6 +163,17 @@ describe('Amazon Payments - Checkout', () => {
user.canGetGems.restore();
});
it('should error if gems block is not valid', async () => {
await expect(amzLib.checkout({
user, orderReferenceId, headers, gemsBlock: 'invalid',
}))
.to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: apiError('invalidGemsBlock'),
name: 'BadRequest',
});
});
it('should gift gems', async () => {
const receivingUser = new User();
await receivingUser.save();
@@ -195,6 +216,7 @@ describe('Amazon Payments - Checkout', () => {
paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT,
headers,
gift,
gemsBlock: undefined,
});
expectAmazonStubs();
});

View File

@@ -5,7 +5,6 @@ import applePayments from '../../../../../website/server/libs/payments/apple';
import iap from '../../../../../website/server/libs/inAppPurchases';
import { model as User } from '../../../../../website/server/models/user';
import common from '../../../../../website/common';
import { mockFindById, restoreFindById } from '../../../../helpers/mongoose.helper';
const { i18n } = common;
@@ -84,7 +83,7 @@ describe('Apple Payments', () => {
user.canGetGems.restore();
});
it('errors if amount does not exist', async () => {
it('errors if gemsBlock does not exist', async () => {
sinon.stub(user, 'canGetGems').resolves(true);
iapGetPurchaseDataStub.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
@@ -106,23 +105,23 @@ describe('Apple Payments', () => {
const gemsCanPurchase = [
{
productId: 'com.habitrpg.ios.Habitica.4gems',
amount: 1,
gemsBlock: '4gems',
},
{
productId: 'com.habitrpg.ios.Habitica.20gems',
amount: 5.25,
gemsBlock: '21gems',
},
{
productId: 'com.habitrpg.ios.Habitica.21gems',
amount: 5.25,
gemsBlock: '21gems',
},
{
productId: 'com.habitrpg.ios.Habitica.42gems',
amount: 10.5,
gemsBlock: '42gems',
},
{
productId: 'com.habitrpg.ios.Habitica.84gems',
amount: 21,
gemsBlock: '84gems',
},
];
@@ -149,8 +148,9 @@ describe('Apple Payments', () => {
expect(paymentBuyGemsStub).to.be.calledWith({
user,
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
amount: gemTest.amount,
gemsBlock: common.content.gems[gemTest.gemsBlock],
headers,
gift: undefined,
});
expect(user.canGetGems).to.be.calledOnce;
user.canGetGems.restore();
@@ -161,8 +161,6 @@ describe('Apple Payments', () => {
const receivingUser = new User();
await receivingUser.save();
mockFindById(receivingUser);
iapGetPurchaseDataStub.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
@@ -184,12 +182,17 @@ describe('Apple Payments', () => {
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user: receivingUser,
user,
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
amount: gemsCanPurchase[0].amount,
headers,
gift: {
type: 'gems',
gems: { amount: 4 },
member: sinon.match({ _id: receivingUser._id }),
uuid: receivingUser._id,
},
gemsBlock: common.content.gems['4gems'],
});
restoreFindById();
});
});

View File

@@ -0,0 +1,14 @@
import common from '../../../../../website/common';
import { getGemsBlock } from '../../../../../website/server/libs/payments/gems';
describe('payments/gems', () => {
describe('#getGemsBlock', () => {
it('throws an error if the gem block key is invalid', () => {
expect(() => getGemsBlock('invalid')).to.throw;
});
it('returns the gem block for the given key', () => {
expect(getGemsBlock('21gems')).to.equal(common.content.gems['21gems']);
});
});
});

View File

@@ -5,7 +5,6 @@ import googlePayments from '../../../../../website/server/libs/payments/google';
import iap from '../../../../../website/server/libs/inAppPurchases';
import { model as User } from '../../../../../website/server/models/user';
import common from '../../../../../website/common';
import { mockFindById, restoreFindById } from '../../../../helpers/mongoose.helper';
const { i18n } = common;
@@ -14,7 +13,7 @@ describe('Google Payments', () => {
describe('verifyGemPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
headers; const gemsBlock = common.content.gems['21gems'];
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentBuyGemsStub;
@@ -103,8 +102,9 @@ describe('Google Payments', () => {
expect(paymentBuyGemsStub).to.be.calledWith({
user,
paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE,
amount: 5.25,
gemsBlock,
headers,
gift: undefined,
});
expect(user.canGetGems).to.be.calledOnce;
user.canGetGems.restore();
@@ -114,8 +114,6 @@ describe('Google Payments', () => {
const receivingUser = new User();
await receivingUser.save();
mockFindById(receivingUser);
const gift = { uuid: receivingUser._id };
await googlePayments.verifyGemPurchase({
user, gift, receipt, signature, headers,
@@ -132,12 +130,17 @@ describe('Google Payments', () => {
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user: receivingUser,
user,
paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE,
amount: 5.25,
gemsBlock,
headers,
gift: {
type: 'gems',
gems: { amount: 21 },
member: sinon.match({ _id: receivingUser._id }),
uuid: receivingUser._id,
},
});
restoreFindById();
});
});

View File

@@ -1,6 +1,7 @@
import moment from 'moment';
import * as sender from '../../../../../website/server/libs/email';
import common from '../../../../../website/common';
import api from '../../../../../website/server/libs/payments/payments';
import * as analytics from '../../../../../website/server/libs/analyticsService';
import * as notifications from '../../../../../website/server/libs/pushNotifications';
@@ -9,6 +10,7 @@ import { translate as t } from '../../../../helpers/api-integration/v3';
import {
generateGroup,
} from '../../../../helpers/api-unit.helper';
import * as worldState from '../../../../../website/server/libs/worldState';
describe('payments/index', () => {
let user; let group; let data; let
@@ -555,6 +557,7 @@ describe('payments/index', () => {
beforeEach(() => {
data = {
user,
gemsBlock: common.content.gems['21gems'],
paymentMethod: 'payment',
headers: {
'x-client': 'habitica-web',
@@ -564,22 +567,6 @@ describe('payments/index', () => {
});
context('Self Purchase', () => {
it('amount property defaults to 5', async () => {
expect(user.balance).to.eql(0);
await api.buyGems(data);
expect(user.balance).to.eql(5);
});
it('can set amount that is purchased', async () => {
data.amount = 13;
await api.buyGems(data);
expect(user.balance).to.eql(13);
});
it('sends a donation email', async () => {
await api.buyGems(data);
@@ -588,6 +575,51 @@ describe('payments/index', () => {
});
});
context('No Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEvent').returns(null);
});
afterEach(() => {
worldState.getCurrentEvent.restore();
});
it('does not apply a discount', async () => {
const balanceBefore = user.balance;
await api.buyGems(data);
const balanceAfter = user.balance;
const balanceDiff = balanceAfter - balanceBefore;
expect(balanceDiff * 4).to.eql(21);
});
});
context('Active Promotion', () => {
beforeEach(() => {
sinon.stub(worldState, 'getCurrentEvent').returns({
...common.content.events.fall2020,
event: 'fall2020',
});
});
afterEach(() => {
worldState.getCurrentEvent.restore();
});
it('applies a discount', async () => {
const balanceBefore = user.balance;
await api.buyGems(data);
const balanceAfter = user.balance;
const balanceDiff = balanceAfter - balanceBefore;
expect(balanceDiff * 4).to.eql(30);
});
});
context('Gift', () => {
let recipient;

View File

@@ -2,11 +2,13 @@
import paypalPayments from '../../../../../../website/server/libs/payments/paypal';
import payments from '../../../../../../website/server/libs/payments/payments';
import { model as User } from '../../../../../../website/server/models/user';
import common from '../../../../../../website/common';
describe('checkout success', () => {
describe('paypal - checkout success', () => {
const subKey = 'basic_3mo';
let user; let gift; let customerId; let
paymentId;
const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey];
let paypalPaymentExecuteStub; let paymentBuyGemsStub; let
paymentsCreateSubscritionStub;
@@ -28,7 +30,7 @@ describe('checkout success', () => {
it('purchases gems', async () => {
await paypalPayments.checkoutSuccess({
user, gift, paymentId, customerId,
user, gift, paymentId, customerId, gemsBlock: gemsBlockKey,
});
expect(paypalPaymentExecuteStub).to.be.calledOnce;
@@ -38,6 +40,7 @@ describe('checkout success', () => {
user,
customerId,
paymentMethod: 'Paypal',
gemsBlock,
});
});

View File

@@ -4,12 +4,14 @@ import nconf from 'nconf';
import paypalPayments from '../../../../../../website/server/libs/payments/paypal';
import { model as User } from '../../../../../../website/server/models/user';
import common from '../../../../../../website/common';
import apiError from '../../../../../../website/server/libs/apiError';
const BASE_URL = nconf.get('BASE_URL');
const { i18n } = common;
describe('checkout', () => {
describe('paypal - checkout', () => {
const subKey = 'basic_3mo';
const gemsBlockKey = '21gems';
let paypalPaymentCreateStub;
let approvalHerf;
@@ -53,10 +55,10 @@ describe('checkout', () => {
});
it('creates a link for gem purchases', async () => {
const link = await paypalPayments.checkout({ user: new User() });
const link = await paypalPayments.checkout({ user: new User(), gemsBlock: gemsBlockKey });
expect(paypalPaymentCreateStub).to.be.calledOnce;
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 5.00));
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99));
expect(link).to.eql(approvalHerf);
});
@@ -83,13 +85,25 @@ describe('checkout', () => {
const user = new User();
sinon.stub(user, 'canGetGems').resolves(false);
await expect(paypalPayments.checkout({ user })).to.eventually.be.rejected.and.to.eql({
await expect(paypalPayments.checkout({ user, gemsBlock: gemsBlockKey }))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
message: i18n.t('groupPolicyCannotGetGems'),
name: 'NotAuthorized',
});
});
it('should error if the gems block is not valid', async () => {
const user = new User();
await expect(paypalPayments.checkout({ user, gemsBlock: 'invalid' }))
.to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: apiError('invalidGemsBlock'),
name: 'BadRequest',
});
});
it('creates a link for gifting gems', async () => {
const receivingUser = new User();
await receivingUser.save();

View File

@@ -6,7 +6,7 @@ import {
} from '../../../../../helpers/api-unit.helper';
import { model as User } from '../../../../../../website/server/models/user';
describe('ipn', () => {
describe('paypal - ipn', () => {
const subKey = 'basic_3mo';
let user; let group; let txn_type; let userPaymentId; let
groupPaymentId;

View File

@@ -10,7 +10,7 @@ import { createNonLeaderGroupMember } from '../paymentHelpers';
const { i18n } = common;
describe('subscribeCancel', () => {
describe('paypal - subscribeCancel', () => {
const subKey = 'basic_3mo';
let user; let group; let groupId; let customerId; let groupCustomerId; let
nextBillingDate;

View File

@@ -7,7 +7,7 @@ import {
import { model as User } from '../../../../../../website/server/models/user';
import common from '../../../../../../website/common';
describe('subscribeSuccess', () => {
describe('paypal - subscribeSuccess', () => {
const subKey = 'basic_3mo';
let user; let group; let block; let groupId; let token; let headers; let
customerId;

View File

@@ -8,7 +8,7 @@ import common from '../../../../../../website/common';
const { i18n } = common;
describe('subscribe', () => {
describe('paypal - subscribe', () => {
const subKey = 'basic_3mo';
let coupon; let sub; let
approvalHerf;

View File

@@ -4,7 +4,7 @@ import api from '../../../../../../website/server/libs/payments/payments';
const groupPlanId = api.constants.GROUP_PLAN_CUSTOMER_ID;
describe('#calculateSubscriptionTerminationDate', () => {
describe('stripe - #calculateSubscriptionTerminationDate', () => {
let plan;
let nextBill;

View File

@@ -10,7 +10,7 @@ import common from '../../../../../../website/common';
const { i18n } = common;
describe('cancel subscription', () => {
describe('stripe - cancel subscription', () => {
const subKey = 'basic_3mo';
const stripe = stripeModule('test');
let user; let groupId; let

View File

@@ -12,7 +12,7 @@ import common from '../../../../../../website/common';
const { i18n } = common;
describe('checkout with subscription', () => {
describe('stripe - checkout with subscription', () => {
const subKey = 'basic_3mo';
const stripe = stripeModule('test');
let user; let group; let data; let gift; let sub;

View File

@@ -4,16 +4,17 @@ import { model as User } from '../../../../../../website/server/models/user';
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
import payments from '../../../../../../website/server/libs/payments/payments';
import common from '../../../../../../website/common';
import apiError from '../../../../../../website/server/libs/apiError';
const { i18n } = common;
describe('checkout', () => {
describe('stripe - checkout', () => {
const subKey = 'basic_3mo';
const stripe = stripeModule('test');
let stripeChargeStub; let paymentBuyGemsStub; let
paymentCreateSubscritionStub;
let user; let gift; let groupId; let email; let headers; let coupon; let customerIdResponse; let
token;
token; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey];
beforeEach(() => {
user = new User();
@@ -89,6 +90,7 @@ describe('checkout', () => {
await expect(stripePayments.checkout({
token,
user,
gemsBlock: gemsBlockKey,
gift,
groupId,
email,
@@ -101,6 +103,25 @@ describe('checkout', () => {
});
});
it('should error if the gems block is invalid', async () => {
gift = undefined;
await expect(stripePayments.checkout({
token,
user,
gemsBlock: 'invalid',
gift,
groupId,
email,
headers,
coupon,
}, stripe)).to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: apiError('invalidGemsBlock'),
name: 'BadRequest',
});
});
it('should purchase gems', async () => {
gift = undefined;
sinon.stub(user, 'canGetGems').resolves(true);
@@ -108,6 +129,7 @@ describe('checkout', () => {
await stripePayments.checkout({
token,
user,
gemsBlock: gemsBlockKey,
gift,
groupId,
email,
@@ -117,7 +139,7 @@ describe('checkout', () => {
expect(stripeChargeStub).to.be.calledOnce;
expect(stripeChargeStub).to.be.calledWith({
amount: 500,
amount: 499,
currency: 'usd',
card: token,
});
@@ -128,6 +150,7 @@ describe('checkout', () => {
customerId: customerIdResponse,
paymentMethod: 'Stripe',
gift,
gemsBlock,
});
expect(user.canGetGems).to.be.calledOnce;
user.canGetGems.restore();
@@ -167,6 +190,7 @@ describe('checkout', () => {
customerId: customerIdResponse,
paymentMethod: 'Gift',
gift,
gemsBlock: undefined,
});
});
@@ -205,6 +229,7 @@ describe('checkout', () => {
customerId: customerIdResponse,
paymentMethod: 'Gift',
gift,
gemsBlock: undefined,
});
});
});

View File

@@ -9,7 +9,7 @@ import common from '../../../../../../website/common';
const { i18n } = common;
describe('edit subscription', () => {
describe('stripe - edit subscription', () => {
const subKey = 'basic_3mo';
const stripe = stripeModule('test');
let user; let groupId; let group; let

View File

@@ -14,7 +14,10 @@ describe('payments - stripe - #checkout', () => {
});
it('verifies credentials', async () => {
await expect(user.post(endpoint, { id: 123 })).to.eventually.be.rejected.and.include({
await expect(user.post(
`${endpoint}?gemsBlock=4gems`,
{ id: 123 },
)).to.eventually.be.rejected.and.include({
code: 401,
error: 'Error',
// message: 'Invalid API Key provided: aaaabbbb********************1111',

View File

@@ -4,6 +4,8 @@ import {
requester,
resetHabiticaDB,
} from '../../../../helpers/api-integration/v3';
import * as worldState from '../../../../../website/server/libs/worldState';
import common from '../../../../../website/common';
describe('GET /world-state', () => {
before(async () => {
@@ -39,4 +41,41 @@ describe('GET /world-state', () => {
expect(res).to.have.nested.property('npcImageSuffix');
expect(res.npcImageSuffix).to.be.a('string');
});
context('no current event', () => {
beforeEach(async () => {
sinon.stub(worldState, 'getCurrentEvent').returns(null);
});
afterEach(() => {
worldState.getCurrentEvent.restore();
});
it('returns null for the current event when there is none active', async () => {
const res = await requester().get('/world-state');
expect(res.currentEvent).to.be.null;
});
});
context('no current event', () => {
const evt = {
...common.content.events.fall2020,
event: 'fall2020',
};
beforeEach(async () => {
sinon.stub(worldState, 'getCurrentEvent').returns(evt);
});
afterEach(() => {
worldState.getCurrentEvent.restore();
});
it('returns the current event when there is an active one', async () => {
const res = await requester().get('/world-state');
expect(res.currentEvent).to.eql(evt);
});
});
});

View File

@@ -26,7 +26,6 @@
id="app"
:class="{
'casting-spell': castingSpell,
'resting': showRestingBanner
}"
>
<!-- <banned-account-modal /> -->
@@ -38,31 +37,8 @@
<router-view v-if="!isUserLoggedIn || isStaticPage" />
<template v-else>
<template v-if="isUserLoaded">
<div
v-show="showRestingBanner"
ref="restingBanner"
class="resting-banner"
>
<span class="content">
<span class="label d-inline d-sm-none">{{ $t('innCheckOutBannerShort') }}</span>
<span class="label d-none d-sm-inline">{{ $t('innCheckOutBanner') }}</span>
<span class="separator">|</span>
<span
class="resume"
@click="resumeDamage()"
>{{ $t('resumeDamage') }}</span>
</span>
<div
class="closepadding"
@click="hideBanner()"
>
<span
class="svg-icon inline icon-10"
aria-hidden="true"
v-html="icons.close"
></span>
</div>
</div>
<damage-paused-banner />
<gems-promo-banner />
<notifications-display />
<app-menu />
<div
@@ -99,20 +75,11 @@
<style lang='scss' scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/variables.scss';
#app {
display: flex;
flex-direction: column;
overflow-x: hidden;
&.resting {
--banner-resting-height: #{$restingToolbarHeight};
}
&.giftingBanner {
--banner-gifting-height: 2.5rem;
}
}
#loading-screen-inapp {
@@ -143,15 +110,6 @@
cursor: crosshair;
}
.closepadding {
margin: 11px 24px;
display: inline-block;
position: relative;
right: 0;
top: 0;
cursor: pointer;
}
.container-fluid {
flex: 1 0 auto;
}
@@ -172,51 +130,11 @@
margin-top: .5em;
margin-bottom: .5em;
}
.resting-banner {
width: 100%;
height: $restingToolbarHeight;
background-color: $blue-10;
top: 0;
z-index: 1300;
display: flex;
.content {
line-height: 1.71;
text-align: center;
color: $white;
padding: 8px 38px 8px 8px;
margin: auto;
}
@media only screen and (max-width: 768px) {
.content {
font-size: 12px;
line-height: 1.4;
}
}
.separator {
color: $blue-100;
margin: 0px 15px;
}
.resume {
font-weight: bold;
cursor: pointer;
white-space:nowrap;
}
}
</style>
<style lang='scss'>
@import '~@/assets/scss/colors.scss';
.closepadding span svg path {
stroke: #FFF;
opacity: 0.48;
}
.modal-backdrop {
opacity: .9 !important;
background-color: $purple-100 !important;
@@ -234,6 +152,8 @@ import { loadProgressBar } from 'axios-progress-bar';
import AppMenu from './components/header/menu';
import AppHeader from './components/header/index';
import DamagePausedBanner from './components/header/banners/damagePaused';
import GemsPromoBanner from './components/header/banners/gemsPromo';
import AppFooter from './components/appFooter';
import notificationsDisplay from './components/notifications';
import snackbars from './components/snackbars/notifications';
@@ -255,8 +175,6 @@ import {
removeLocalSetting,
} from '@/libs/userlocalManager';
import svgClose from '@/assets/svg/close.svg';
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
export default {
@@ -265,6 +183,8 @@ export default {
AppMenu,
AppHeader,
AppFooter,
DamagePausedBanner,
GemsPromoBanner,
notificationsDisplay,
snackbars,
BuyModal,
@@ -277,9 +197,6 @@ export default {
mixins: [notifications, spellsMixin],
data () {
return {
icons: Object.freeze({
close: svgClose,
}),
selectedItemToBuy: null,
selectedSpellToBuy: null,
@@ -299,9 +216,6 @@ export default {
castingSpell () {
return this.$store.state.spellOptions.castingSpell;
},
showRestingBanner () {
return !this.bannerHidden && this.user && this.user.preferences.sleep;
},
noMargin () {
return ['privateMessages'].includes(this.$route.name);
},
@@ -589,12 +503,6 @@ export default {
hideLoadingScreen () {
this.loading = false;
},
hideBanner () {
this.bannerHidden = true;
},
resumeDamage () {
this.$store.dispatch('user:sleep');
},
},
};
</script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -33,7 +33,6 @@
height: 24px;
}
.icon-10 {
width: 10px;
height: 10px;

View File

@@ -35,6 +35,5 @@
@import './animals';
@import './iconalert';
@import './tiers';
@import './payments';
@import './spacing';
@import './modal';

View File

@@ -6,7 +6,7 @@
}
.modal-dialog {
margin: 5.5rem auto 3rem;
margin: 3rem auto 3rem;
width: auto;
.title {

View File

@@ -1,44 +0,0 @@
.payments-column {
display: flex;
flex-direction: column;
width: 296px;
justify-content: center;
&.payments-disabled {
opacity: 0.64;
.btn, .btn:hover, .btn:active {
box-shadow: none;
}
.payment-item > *{
cursor: default !important;
}
}
.payment-item {
margin-bottom: 12px;
display: flex;
&.payment-button {
display: flex;
justify-content: center;
align-items: center;
.credit-card-icon {
width: 21.3px;
height: 16px;
margin-right: 8.7px;
}
&.paypal-checkout {
background: #009cde;
img {
width: 157px;
height: 21px;
}
}
}
}
}

View File

@@ -8,5 +8,4 @@ $npc_seasonal_flavor: 'normal';
$npc_timetravelers_flavor: 'normal';
$npc_tavern_flavor: 'normal';
$restingToolbarHeight: 40px;
$menuToolbarHeight: 56px;

View File

@@ -5,7 +5,7 @@
:title="title"
ok-only
:ok-title="$t('onwards')"
v-bind:footer-class="{ greyed: displayRewardQuest }"
:footer-class="{ greyed: displayRewardQuest }"
>
<section class="d-flex">
<span
@@ -23,8 +23,8 @@
</section>
<p
class="text"
v-once
class="text"
>
{{ $t('levelup') }}
</p>
@@ -34,8 +34,8 @@
class="greyed"
>
<div
class="your-rewards d-flex"
v-once
class="your-rewards d-flex"
>
<span
class="sparkles"

View File

@@ -83,24 +83,12 @@
class="col-12"
>
<h2>{{ $t('choosePaymentMethod') }}</h2>
<div class="payments-column">
<button
class="purchase btn btn-primary payment-button payment-item"
@click="pay(PAYMENTS.STRIPE)"
>
<div
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<amazon-button
class="payment-item"
<payments-buttons
:stripe-fn="() => pay(PAYMENTS.STRIPE)"
:amazon-data="pay(PAYMENTS.AMAZON)"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@@ -148,21 +136,16 @@
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import paymentsMixin from '../../mixins/payments';
import amazonButton from '@/components/payments/amazonButton';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
import paymentsButtons from '@/components/payments/buttons/list';
export default {
components: {
amazonButton,
paymentsButtons,
},
mixins: [paymentsMixin],
data () {
return {
amazonPayments: {},
icons: Object.freeze({
creditCardIcon,
}),
PAGES: {
CREATE_GROUP: 'create-group',
UPGRADE_GROUP: 'upgrade-group',

View File

@@ -60,7 +60,7 @@
Are you ready to upgrade?
</h1>
<div class="row">
<div class="col-12 text-center">
<div class="col-12 text-center mb-4 d-flex justify-content-center">
<div class="purple-box">
<div class="amount-section">
<div class="dollar">
@@ -93,26 +93,14 @@
</div>
<div class="box payment-providers">
<h3>Choose your payment method</h3>
<div class="payments-column">
<button
class="purchase btn btn-primary payment-button payment-item"
@click="pay(PAYMENTS.STRIPE)"
>
<div
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<amazon-button
class="payment-item"
<payments-buttons
:stripe-fn="() => pay(PAYMENTS.STRIPE)"
:amazon-data="pay(PAYMENTS.AMAZON)"
/>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!upgradingGroup._id"
class="container col-6 offset-3 create-option"
@@ -254,24 +242,12 @@
>
<div class="text-center">
<h3>Choose your payment method</h3>
<div class="payments-column mx-auto">
<button
class="purchase btn btn-primary payment-button payment-item"
@click="pay(PAYMENTS.STRIPE)"
>
<div
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<amazon-button
class="payment-item"
<payments-buttons
:stripe-fn="() => pay(PAYMENTS.STRIPE)"
:amazon-data="pay(PAYMENTS.AMAZON)"
/>
</div>
</div>
</div>
</b-modal>
</div>
</template>
@@ -290,7 +266,6 @@
.purple-box {
color: #bda8ff;
margin-bottom: 2em;
}
.number {
@@ -464,19 +439,17 @@
import paymentsMixin from '../../mixins/payments';
import { mapState } from '@/libs/store';
import positiveIcon from '@/assets/svg/positive.svg';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
import amazonButton from '@/components/payments/amazonButton';
import paymentsButtons from '@/components/payments/buttons/list';
export default {
components: {
amazonButton,
paymentsButtons,
},
mixins: [paymentsMixin],
data () {
return {
amazonPayments: {},
icons: Object.freeze({
creditCardIcon,
positiveIcon,
}),
PAGES: {

View File

@@ -0,0 +1,122 @@
<template>
<div
v-if="canShow"
class="habitica-top-banner d-flex justify-content-between align-items-center"
:class="bannerClass"
:style="{height}"
>
<slot name="content"></slot>
<div
v-if="canClose"
class="close-icon svg-icon icon-12"
@click="close()"
v-html="icons.close"
></div>
</div>
</template>
<style lang="scss">
body.modal-open .habitica-top-banner {
z-index: 1035;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.habitica-top-banner {
width: 100%;
padding-left: 1.5rem;
padding-right: 1.625rem;
z-index: 1300;
}
.svg-icon {
position: relative;
top: 0;
right: 0;
opacity: 0.48;
& ::v-deep svg path {
stroke: $white !important;
}
&:hover {
opacity: 0.75;
}
}
</style>
<script>
import closeIcon from '@/assets/svg/close.svg';
export default {
props: {
bannerId: {
type: String,
required: true,
},
bannerClass: {
type: String,
default: '',
},
canClose: {
type: Boolean,
default: true,
},
show: {
type: Boolean,
default: true,
},
// Used to correctly show the layout on certain pages with a fixed height
// Like the PMs page
height: {
type: String,
required: true,
},
},
data () {
return {
icons: Object.freeze({
close: closeIcon,
}),
hidden: false,
};
},
computed: {
canShow () {
return !this.hidden && this.show;
},
},
watch: {
canShow: {
handler (newVal) {
const valToSet = newVal === true ? this.height : '0px';
document.documentElement.style
.setProperty(`--banner-${this.bannerId}-height`, valToSet);
},
immediate: true,
},
show (newVal) {
// When the show condition is set to false externally, remove the session storage setting
if (newVal === false) {
window.sessionStorage.removeItem(`hide-banner-${this.bannerId}`);
this.hidden = false;
}
},
},
mounted () {
const hideStatus = window.sessionStorage.getItem(`hide-banner-${this.bannerId}`);
if (hideStatus === 'true') {
this.hidden = true;
}
},
methods: {
close () {
window.sessionStorage.setItem(`hide-banner-${this.bannerId}`, 'true');
this.hidden = true;
},
},
};
</script>

View File

@@ -0,0 +1,84 @@
<template>
<base-banner
banner-id="damage-paused"
banner-class="resting-banner"
:show="showRestingBanner"
height="40px"
>
<div
slot="content"
class="content"
>
<span
v-once
class="label d-inline d-sm-none"
>{{ $t('innCheckOutBannerShort') }}</span>
<span
v-once
class="label d-none d-sm-inline"
>{{ $t('innCheckOutBanner') }}</span>
<span class="separator">|</span>
<span
v-once
class="resume"
@click="resumeDamage()"
>{{ $t('resumeDamage') }}</span>
</div>
</base-banner>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.resting-banner {
background-color: $blue-10;
.content {
line-height: 1.71;
text-align: center;
color: $white;
padding: 0.5rem;
margin: auto;
}
@media only screen and (max-width: 992px) {
.content {
font-size: 12px;
line-height: 1.4;
}
}
.separator {
color: $blue-100;
margin: 0 1rem;
}
.resume {
font-weight: bold;
cursor: pointer;
white-space: nowrap;
}
}
</style>
<script>
import { mapState } from '@/libs/store';
import BaseBanner from './base';
export default {
components: {
BaseBanner,
},
computed: {
...mapState({ user: 'user.data' }),
showRestingBanner () {
return this.user && this.user.preferences.sleep;
},
},
methods: {
resumeDamage () {
this.$store.dispatch('user:sleep');
},
},
};
</script>

View File

@@ -0,0 +1,132 @@
<template>
<base-banner
banner-id="gems-promo"
:banner-class="bannerClass"
:show="showGemsPromoBanner"
height="3rem"
>
<div
slot="content"
:aria-label="$t('gems')"
class="content d-flex justify-content-around align-items-center"
@click="openGemsModal"
>
<img
v-if="eventName === 'fall2020'"
class="d-none d-xl-block"
srcset="
~@/assets/images/gems/fall-confetti-left/confetti.png,
~@/assets/images/gems/fall-confetti-left/confetti@2x.png 2x,
~@/assets/images/gems/fall-confetti-left/confetti@3x.png 3x"
src="~@/assets/images/gems/fall-confetti-left/confetti.png"
>
<img
v-else-if="eventName === 'fall2020SecondPromo'"
class="d-none d-xl-block"
srcset="
~@/assets/images/gems/spooky-confetti-left/confetti.png,
~@/assets/images/gems/spooky-confetti-left/confetti@2x.png 2x,
~@/assets/images/gems/spooky-confetti-left/confetti@3x.png 3x"
src="~@/assets/images/gems/spooky-confetti-left/confetti.png"
>
<div class="promo-test">
<img
v-if="eventName === 'fall2020'"
srcset="
~@/assets/images/gems/fall-text/text.png,
~@/assets/images/gems/fall-text/text@2x.png 2x,
~@/assets/images/gems/fall-text/text@3x.png 3x"
src="~@/assets/images/gems/fall-text/text.png"
>
<img
v-else-if="eventName === 'fall2020SecondPromo'"
srcset="
~@/assets/images/gems/spooky-text/text.png,
~@/assets/images/gems/spooky-text/text@2x.png 2x,
~@/assets/images/gems/spooky-text/text@3x.png 3x"
src="~@/assets/images/gems/spooky-text/text.png"
>
</div>
<img
v-if="eventName === 'fall2020'"
class="d-none d-xl-block"
srcset="
~@/assets/images/gems/fall-confetti-right/confetti.png,
~@/assets/images/gems/fall-confetti-right/confetti@2x.png 2x,
~@/assets/images/gems/fall-confetti-right/confetti@3x.png 3x"
src="~@/assets/images/gems/fall-confetti-right/confetti.png"
>
<img
v-else-if="eventName === 'fall2020SecondPromo'"
class="d-none d-xl-block"
srcset="
~@/assets/images/gems/spooky-confetti-right/confetti.png,
~@/assets/images/gems/spooky-confetti-right/confetti@2x.png 2x,
~@/assets/images/gems/spooky-confetti-right/confetti@3x.png 3x"
src="~@/assets/images/gems/spooky-confetti-right/confetti.png"
>
</div>
</base-banner>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.gems-promo-banner-fall2020 {
background: $gray-10;
}
.gems-promo-banner-fall2020SecondPromo {
background: $black;
}
.gems-promo-banner {
.content {
width: 100%;
cursor: pointer;
}
}
</style>
<script>
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import BaseBanner from './base';
export default {
components: {
BaseBanner,
},
computed: {
...mapState({
currentEvent: 'worldState.data.currentEvent',
}),
eventName () {
return this.currentEvent && this.currentEvent.event;
},
showGemsPromoBanner () {
const currEvt = this.currentEvent;
if (!currEvt) return false;
return currEvt.event === 'fall2020' || currEvt.event === 'fall2020SecondPromo';
},
bannerClass () {
const bannerClass = 'gems-promo-banner';
if (!this.showGemsPromoBanner) return bannerClass;
return `${bannerClass} ${bannerClass}-${this.eventName}`;
},
},
methods: {
openGemsModal () {
Analytics.track({
hitType: 'event',
eventCategory: 'button',
eventAction: 'click',
eventLabel: 'Gems Promo Banner',
});
this.$root.$emit('bv::show::modal', 'buy-gems', { alreadyTracked: true });
},
},
};
</script>

View File

@@ -6,10 +6,10 @@
<send-gems-modal />
<select-user-modal />
<b-navbar
id="habitica-menu"
class="topbar navbar-inverse static-top"
toggleable="lg"
type="dark"
:class="navbarZIndexClass"
>
<b-navbar-brand
class="brand"
@@ -399,6 +399,12 @@
</div>
</template>
<style lang="scss">
body.modal-open #habitica-menu {
z-index: 1035;
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
@import '~@/assets/scss/utils.scss';
@@ -546,6 +552,7 @@
}
.topbar {
z-index: 1080;
background: $purple-100 url(~@/assets/svg/for-css/bits.svg) right top no-repeat;
min-height: 56px;
box-shadow: 0 1px 2px 0 rgba($black, 0.24);
@@ -555,16 +562,6 @@
}
}
.navbar-z-index {
&-normal {
z-index: 1080;
}
&-modal {
z-index: 1035;
}
}
.logo {
padding-left: 10px;
width: 128px;
@@ -764,12 +761,6 @@ export default {
groupPlans: 'groupPlans.data',
modalStack: 'modalStack',
}),
navbarZIndexClass () {
if (this.modalStack.length > 0) {
return 'navbar-z-index-modal';
}
return 'navbar-z-index-normal';
},
},
mounted () {
this.getUserGroupPlans();

View File

@@ -70,6 +70,7 @@ export default {
amazonPayments: {
modal: null,
type: null,
gemsBlock: null,
gift: null,
loggedIn: false,
paymentSelected: false,
@@ -201,6 +202,8 @@ export default {
} else if (paymentType.indexOf('gift-') === 0) {
appState.gift = this.amazonPayments.gift;
appState.giftReceiver = this.amazonPayments.giftReceiver;
} else if (paymentType === 'gems') {
appState.gemsBlock = this.amazonPayments.gemsBlock;
}
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
@@ -218,12 +221,17 @@ export default {
// @TODO: A gift should not read the same as buying gems for yourself.
if (this.amazonPayments.type === 'single') {
const url = '/amazon/checkout';
try {
await axios.post(url, {
const data = {
orderReferenceId: this.amazonPayments.orderReferenceId,
gift: this.amazonPayments.gift,
});
};
if (this.amazonPayments.gemsBlock) {
data.gemsBlock = this.amazonPayments.gemsBlock.key;
}
try {
await axios.post(url, data);
this.$set(this, 'amazonButtonEnabled', true);
this.storePaymentStatusAndReload();

View File

@@ -2,6 +2,7 @@
<div
:id="buttonId"
class="amazon-pay-button"
:class="{disabled}"
></div>
</template>
@@ -14,8 +15,11 @@ import paymentsMixin from '@/mixins/payments';
export default {
mixins: [paymentsMixin],
props: {
amazonData: Object,
amazonDisabled: {
amazonData: {
type: Object,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
@@ -25,6 +29,7 @@ export default {
amazonPayments: {
modal: null,
type: null,
gemsBlock: null,
gift: null,
loggedIn: false,
paymentSelected: false,
@@ -42,7 +47,6 @@ export default {
};
},
computed: {
...mapState({ user: 'user.data' }),
...mapState(['isAmazonReady']),
amazonPaymentsCanCheckout () {
if (this.amazonPayments.type === 'single') {
@@ -87,7 +91,7 @@ export default {
size: 'large',
agreementType: 'BillingAgreement',
onSignIn: async contract => { // @TODO send to modal
if (this.amazonDisabled === true) return null;
if (this.disabled === true) return null;
// if (!this.checkGemAmount(this.amazonData)) return;
this.amazonPayments.billingAgreementId = contract.getAmazonBillingAgreementId();
@@ -96,7 +100,7 @@ export default {
return this.$root.$emit('habitica::pay-with-amazon', this.amazonPayments);
},
authorization: () => {
if (this.amazonDisabled === true) return;
if (this.disabled === true) return;
window.amazon.Login.authorize({
scope: 'payments:widget',
@@ -117,3 +121,15 @@ export default {
},
};
</script>
<style lang="scss">
.amazon-pay-button.disabled {
.amazonpay-button-inner-image {
cursor: default !important;
&:focus {
outline: none;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import PaymentsButtonsList from './list.vue';
import getStore from '@/store';
import { setup as setupPayments } from '@/libs/payments';
setupPayments();
storiesOf('Payments Buttons', module)
.add('simple', () => ({
components: { PaymentsButtonsList },
template: `
<div style="position: absolute; margin: 20px">
<payments-buttons-list
:amazon-data="{type: 'single'}"
:stripe-fn="() => {}"
:paypal-fn="() => {}"
></payments-buttons-list>
</div>
`,
store: getStore(),
}))
.add('disabled', () => ({
components: { PaymentsButtonsList },
template: `
<div style="position: absolute; margin: 20px">
<payments-buttons-list
:disabled="true"
:amazon-data="{type: 'single'}"
:stripe-fn="() => {}"
:paypal-fn="() => {}"
></payments-buttons-list>
</div>
`,
store: getStore(),
}))
.add('only stripe and amazon (example)', () => ({
components: { PaymentsButtonsList },
template: `
<div style="position: absolute; margin: 20px">
<payments-buttons-list
:amazon-data="{type: 'single'}"
:stripe-fn="() => {}"
></payments-buttons-list>
</div>
`,
store: getStore(),
}));

View File

@@ -0,0 +1,127 @@
<template>
<div class="payments-column mx-auto mt-auto">
<button
v-if="stripeAvailable"
class="btn btn-primary payment-button payment-item"
:class="{disabled}"
:disabled="disabled"
@click="stripeFn()"
>
<div
v-once
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<button
v-if="paypalAvailable"
class="btn payment-item paypal-checkout payment-button"
:class="{disabled}"
:disabled="disabled"
@click="paypalFn()"
>
&nbsp;
<img
src="~@/assets/images/paypal-checkout.png"
:alt="$t('paypal')"
>&nbsp;
</button>
<amazon-button
v-if="amazonAvailable"
class="payment-item"
:disabled="disabled"
:amazon-data="amazonData"
/>
</div>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.payments-column {
display: flex;
flex-direction: column;
width: 296px;
justify-content: center;
.payment-item {
margin-bottom: 12px;
display: flex;
&.payment-button {
display: flex;
justify-content: center;
align-items: center;
.credit-card-icon {
width: 21.3px;
height: 16px;
margin-right: 8.7px;
}
&.paypal-checkout {
background: #009cde;
img {
width: 157px;
height: 21px;
}
}
}
}
}
.disabled {
opacity: 0.64;
.btn, .btn:hover, .btn:active {
box-shadow: none;
cursor: default !important;
}
}
</style>
<script>
import amazonButton from '@/components/payments/buttons/amazon';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
export default {
components: {
amazonButton,
},
props: {
disabled: {
type: Boolean,
default: false,
},
amazonData: {
type: Object,
},
stripeFn: {
type: Function,
},
paypalFn: {
type: Function,
},
},
data () {
return {
icons: Object.freeze({
creditCardIcon,
}),
};
},
computed: {
stripeAvailable () {
return typeof this.stripeFn === 'function';
},
paypalAvailable () {
return typeof this.paypalFn === 'function';
},
amazonAvailable () {
return this.amazonData !== undefined;
},
},
};
</script>

View File

@@ -3,316 +3,333 @@
<b-modal
id="buy-gems"
:hide-footer="true"
size="lg"
size="md"
:modal-class="eventClass"
>
<div
slot="modal-header"
class="header-wrap"
class="header-wrap container"
>
<div class="image-gemfall">
<div class="row">
<h2 class="header-invert mx-auto">
{{ $t('support') }}
</h2>
</div>
<span
v-once
class="close-icon svg-icon inline icon-12"
@click="close()"
v-html="icons.close"
></span>
<div class="row">
<div
class="logo svg-icon mx-auto"
v-html="icons.logo"
></div>
class="col-12 text-center"
>
<img
v-if="eventName === 'fall2020'"
:alt="$t('supportHabitica')"
srcset="
~@/assets/images/gems/fall-header.png,
~@/assets/images/gems/fall-header@2x.png 2x,
~@/assets/images/gems/fall-header@3x.png 3x"
src="~@/assets/images/gems/fall-header.png"
>
<img
v-else-if="eventName === 'fall2020SecondPromo'"
:alt="$t('supportHabitica')"
srcset="
~@/assets/images/gems/spooky-header.png,
~@/assets/images/gems/spooky-header@2x.png 2x,
~@/assets/images/gems/spooky-header@3x.png 3x"
src="~@/assets/images/gems/spooky-header.png"
>
<img
v-else
:alt="$t('supportHabitica')"
srcset="
~@/assets/images/gems/support-habitica.png,
~@/assets/images/gems/support-habitica@2x.png 2x,
~@/assets/images/gems/support-habitica@3x.png 3x"
src="~@/assets/images/gems/support-habitica.png"
>
</div>
</div>
</div>
<div v-if="hasSubscription">
<div class="container">
<div class="row text-center">
<h2 class="mx-auto text-leadin">
{{ $t('subscriptionAlreadySubscribedLeadIn') }}
</h2>
</div>
<div class="row text-center">
<div class="col-6 offset-3">
<p>{{ $t("gemsPurchaseNote") }}</p>
</div>
</div>
</div>
<div class="row text-center">
<h2 class="mx-auto text-leadin">
<h2
v-once
class="col-12 text-leadin"
>
{{ $t('gemBenefitLeadin') }}
</h2>
</div>
<div class="row">
<div class="col">
<div class="row">
<div class="col-md-2 offset-1">
<div class="d-flex bubble justify-content-center align-items-center">
<div
class="svg-icon check mx-auto"
v-html="icons.check"
></div>
</div>
</div>
<div class="col-md-8 align-self-center">
<p>{{ $t('gemBenefit1') }}</p>
</div>
</div>
<div class="row">
<div class="col-md-2 offset-1">
<div class="d-flex bubble justify-content-center align-items-center">
<div
class="svg-icon check mx-auto"
v-html="icons.check"
></div>
</div>
</div>
<div class="col-md-8 align-self-center">
<p>{{ $t('gemBenefit2') }}</p>
</div>
</div>
</div>
<div class="col">
<div class="row">
<div class="col-md-2 offset-1">
<div class="d-flex bubble justify-content-center align-items-center">
<div
class="svg-icon check mx-auto"
v-html="icons.check"
></div>
</div>
</div>
<div class="col-md-8 align-self-center">
<p>{{ $t('gemBenefit3') }}</p>
</div>
</div>
<div class="row">
<div class="col-md-2 offset-1">
<div class="d-flex bubble justify-content-center align-items-center">
<div
class="svg-icon check mx-auto"
v-html="icons.check"
></div>
</div>
</div>
<div class="col-md-8 align-self-center">
<p>{{ $t('gemBenefit4') }}</p>
</div>
</div>
</div>
</div>
<div class="card-deck gem-deck">
<div
class="card text-center col-3"
:class="{active: gemAmount === 20 }"
v-once
class="row gem-benefits pb-2"
>
<div class="card-img-top">
<div
class="mx-auto"
style="'height: 55px; width: 47.5px; margin-top: 1.85em;'"
v-html="icons.twentyOneGems"
v-for="benefit in [1,2,3,4]"
:key="benefit"
class="col-md-6 d-flex pl-4 pr-0 pb-3"
>
<div class="d-flex bubble justify-content-center align-items-center">
<div
class="svg-icon check mx-auto"
v-html="icons.check"
></div>
</div>
<div class="card-body">
<div class="gem-count">
20
<p class="small-text pl-2 mb-0">
{{ $t(`gemBenefit${benefit}`) }}
</p>
</div>
<div class="gem-text">
</div>
<div class="row gem-deck">
<div
v-for="gemsBlock in gemsBlocks"
:key="gemsBlock.key"
class="text-center col-3"
:class="{active: selectedGemsBlock === gemsBlock }"
>
<div
class="gem-icon"
v-html="icons[gemsBlock.key]"
></div>
<div class="gem-count">
{{ gemsBlock.gems }}
</div>
<div
v-once
class="gem-text"
>
{{ $t('gems') }}
</div>
<div class="divider"></div>
<button
class="btn btn-primary"
@click="gemAmount === 20 ? gemAmount = 0 : gemAmount = 20"
v-if="!isSelected(gemsBlock)"
class="btn btn-primary gem-btn"
@click="selectGemsBlock(gemsBlock)"
>
{{ gemAmount === 20 ? $t('selected') : '$5.00' }}
{{ `$${gemsBlock.price / 100}` }}
</button>
</div>
<button
v-else
class="btn btn-success gem-btn"
@click="selectGemsBlock(gemsBlock)"
>
<div
v-once
class="svg-icon check text-white mx-auto"
v-html="icons.check"
></div>
</button>
<span
v-if="gemsBlock.originalGems"
class="small-text original-gems"
>
{{ $t('usuallyGems', {originalGems: gemsBlock.originalGems}) }}
</span>
</div>
</div>
<div class="row text-center">
<h2 class="mx-auto text-payment">
<h4
v-once
class="col-12 text-payment mb-3"
>
{{ $t('choosePaymentMethod') }}
</h2>
</h4>
</div>
<div class="payments-column">
<button
class="purchase btn btn-primary payment-button payment-item"
@click="showStripe({})"
>
<div
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<button
class="btn payment-item paypal-checkout payment-button"
@click="openPaypal(paypalCheckoutLink, 'gems')"
>
&nbsp;
<img
src="~@/assets/images/paypal-checkout.png"
:alt="$t('paypal')"
>&nbsp;
</button>
<amazon-button
class="payment-item"
:amazon-data="{type: 'single'}"
<payments-buttons
:disabled="!selectedGemsBlock"
:stripe-fn="() => showStripe({ gemsBlock: selectedGemsBlock })"
:paypal-fn="() => openPaypal({
url: paypalCheckoutLink, type: 'gems', gemsBlock: selectedGemsBlock
})"
:amazon-data="{type: 'single', gemsBlock: selectedGemsBlock}"
/>
</div>
<div class="row text-center">
<div
class="svg-icon mx-auto"
style="'height: 24px; width: 24px;'"
v-html="icons.heart"
></div>
</div>
<div class="row text-center text-outtro">
<div class="col-6 offset-3">
{{ $t('buyGemsSupportsDevs') }}
</div>
</div>
</b-modal>
</div>
</template>
<style lang="scss">
#buy-gems .modal-body {
@import '~@/assets/scss/colors.scss';
#buy-gems {
.close-icon svg path {
stroke: $purple-400;
}
.close-icon:hover svg path {
stroke: $purple-600;
}
.modal-dialog {
max-width: 35.375rem;
}
.modal-body {
padding: 0;
padding-bottom: 2rem;
background: $white;
border-radius: 0px 0px 8px 8px;
}
#buy-gems .modal-content {
border-radius: 8px;
width: 824px;
.modal-content {
border: none;
background: transparent;
}
#buy-gems .modal-header {
.modal-header {
padding: 0;
border-bottom: 0px;
}
}
// Fall 2020 events styles
#buy-gems.event-fall2020, #buy-gems.event-fall2020SecondPromo {
.header-wrap {
padding-top: 4.5rem;
padding-bottom: 1.5rem;
}
.gem-btn {
background-image: linear-gradient(293deg, $red-100, $orange-50 51%, $yellow-50);
border: none;
&.btn-success {
background: $green-50 !important;
}
}
.gem-count {
color: $orange-50;
}
.close-icon svg path {
stroke: $gray-200;
}
.close-icon:hover svg path {
stroke: $gray-400;
}
}
#buy-gems.event-fall2020 {
.header-wrap {
background-image: url('~@/assets/images/gems/fall-header-bg@2x.png');
background-size: 100%;
}
}
#buy-gems.event-fall2020SecondPromo {
.header-wrap {
background-image: url('~@/assets/images/gems/spooky-header-bg@2x.png');
background-size: 100%;
}
}
</style>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.payments-column {
margin: 0 auto;
}
a.mx-auto {
color: #2995cd;
}
button {
margin-bottom: 1em;
}
p {
font-size: 16px;
.gem-btn {
min-width: 4.813rem;
min-height: 2rem;
margin-bottom: 0.5rem;
}
.bubble {
width: 32px;
height: 32px;
width: 2rem;
height: 2rem;
border-radius: 1000px;
border: solid 2px #e1e0e3;
border: 2px solid $gray-400;
}
.gem-benefits {
p {
font-style: normal;
color: $gray-100;
max-width: 200px;
}
}
.gem-icon {
margin: 0 auto;
height: 55px;
width: 47.5px;
margin-top: 1.85em;
margin-bottom: 0.625rem;
}
.original-gems {
margin-top: 0.5rem;
font-style: normal;
color: $gray-300;
}
.gem-deck {
align-items: center;
justify-content: center;
}
.divider {
width: 80%;
height: 1px;
background-color: #e1e0e3;
margin: 1em auto;
background: $gray-700;
color: $gray-100;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.gem-count {
font-family: Roboto;
font-size: 40px;
font-size: 2rem;
font-weight: bold;
color: #2995cd;
line-height: 1.25;
}
.gem-text {
font-family: Roboto;
font-size: 16px;
color: #a5a1ac;
margin-bottom: 1em;
}
.image-gemfall {
background: url(~@/assets/images/gemfall.png) center repeat-y;
height: 14em;
margin-bottom: 0.5rem;
font-size: 0.875rem;
line-height: 1.71;
}
.header-wrap {
background-image: linear-gradient(74deg, #4f2a93, #6133b4);
height: 14em;
background-image: linear-gradient(75deg, $purple-300, $purple-200 100%);
width: 100%;
color: #4e4a57;
padding: 0;
border-top-left-radius: 7px;
border-top-right-radius: 7px;
}
.logo {
width: 256px;
height: 56px;
padding-top: 3rem;
padding-bottom: 2.5rem;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.svg-icon.check {
color: #bda8ff;
width: 1rem;
}
.header-invert {
margin: 3rem auto 1.5rem;
color: #FFFFFF;
font-family: Roboto;
font-weight: normal;
letter-spacing: 0.18em;
color: $purple-400;
width: 0.77rem;
height: 0.615rem;
}
.text-leadin {
margin: 1rem;
margin: 1.5rem auto;
font-weight: bold;
color: $purple-200;
}
.text-outtro {
margin-bottom: 1em;
color: #a5a1ac;
}
.text-payment {
color: #4e4a57;
font-size: 24px;
margin: 1em;
line-height: 1.71;
color: $gray-50;
}
</style>
<script>
import moment from 'moment';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import markdown from '@/directives/markdown';
import planGemLimits from '@/../../common/script/libs/planGemLimits';
import paymentsMixin from '@/mixins/payments';
import checkIcon from '@/assets/svg/check.svg';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
import heart from '@/assets/svg/health.svg';
import logo from '@/assets/svg/habitica-logo.svg';
import fourGems from '@/assets/svg/4-gems.svg';
import twentyOneGems from '@/assets/svg/21-gems.svg';
import fortyTwoGems from '@/assets/svg/42-gems.svg';
import eightyFourGems from '@/assets/svg/84-gems.svg';
import svgClose from '@/assets/svg/close.svg';
import amazonButton from '@/components/payments/amazonButton';
import paymentsButtons from '@/components/payments/buttons/list';
export default {
components: {
amazonButton,
paymentsButtons,
},
directives: {
markdown,
@@ -321,30 +338,69 @@ export default {
data () {
return {
icons: Object.freeze({
logo,
close: svgClose,
check: checkIcon,
creditCardIcon,
heart,
twentyOneGems,
'4gems': fourGems,
'21gems': twentyOneGems,
'42gems': fortyTwoGems,
'84gems': eightyFourGems,
}),
gemAmount: 0,
planGemLimits,
selectedGemsBlock: null,
alreadyTracked: false,
};
},
computed: {
...mapState({ user: 'user.data' }),
hasSubscription () {
return Boolean(this.user.purchased.plan.customerId);
...mapState({
user: 'user.data',
originalGemsBlocks: 'content.gems',
currentEvent: 'worldState.data.currentEvent',
}),
eventName () {
return this.currentEvent && this.currentEvent.event;
},
userReachedGemCap () {
return this.user.purchased.plan.customerId
&& this.user.purchased.plan.gemsBought
>= (this.user.purchased.plan.consecutive.gemCapExtra + this.planGemLimits.convCap);
eventClass () {
if (this.currentEvent && this.currentEvent.gemsPromo) {
return `event-${this.eventName}`;
}
return '';
},
isGemsPromoActive () {
const currEvt = this.currentEvent;
if (currEvt && currEvt.gemsPromo && moment().isBefore(currEvt.end)) {
return true;
}
return false;
},
gemsBlocks () {
// We don't want to modify the original gems blocks when a promotion is running
// Also the content data is frozen with Object.freeze and can't be changed
// So we clone the blocks and adjust the number of gems if necessary
const blocks = {};
Object.keys(this.originalGemsBlocks).forEach(gemsBlockKey => {
const originalBlock = this.originalGemsBlocks[gemsBlockKey];
const newBlock = blocks[gemsBlockKey] = { ...originalBlock }; // eslint-disable-line no-multi-assign, max-len
if (this.isGemsPromoActive) {
newBlock.originalGems = originalBlock.gems;
newBlock.gems = (
this.currentEvent.gemsPromo[gemsBlockKey] || originalBlock.gems
);
}
});
return blocks;
},
},
mounted () {
async mounted () {
await this.$store.dispatch('worldState:getWorldState');
this.$root.$on('bv::show::modal', (modalId, data = {}) => {
// We force reloading the world state every time the modal is reopened
// To make sure the promo status is always up to date
this.$store.dispatch('worldState:getWorldState', { forceLoad: true });
// Track opening of gems modal unless it's been already tracked
// For example the gems button in the menu already tracks the event by itself
if (modalId === 'buy-gems' && data.alreadyTracked !== true) {
@@ -358,6 +414,16 @@ export default {
});
},
methods: {
selectGemsBlock (gemsBlock) {
if (gemsBlock === this.selectedGemsBlock) {
this.selectedGemsBlock = null;
} else {
this.selectedGemsBlock = gemsBlock;
}
},
isSelected (gemsBlock) {
return this.selectedGemsBlock === gemsBlock;
},
close () {
this.$root.$emit('bv::hide::modal', 'buy-gems');
},

View File

@@ -15,10 +15,10 @@
<h3 class="panel-heading clearfix">
<div class="float-right">
<span
v-if="gift.gems.fromBalance"
v-if="fromBal"
>{{ $t('sendGiftGemsBalance', {number: userLoggedIn.balance * 4}) }}</span>
<span
v-if="!gift.gems.fromBalance"
v-else
>{{ $t('sendGiftCost', {cost: gift.gems.amount / 4}) }}</span>
</div>
{{ $t('gemsPopoverTitle') }}
@@ -32,15 +32,15 @@
type="number"
placeholder="Number of Gems"
min="0"
:max="gift.gems.fromBalance ? userLoggedIn.balance * 4 : 9999"
:max="fromBal ? userLoggedIn.balance * 4 : 9999"
>
</div>
<div class="btn-group ml-auto">
<button
class="btn"
:class="{
'btn-primary': gift.gems.fromBalance,
'btn-secondary': !gift.gems.fromBalance,
'btn-primary': fromBal,
'btn-secondary': !fromBal,
}"
@click="gift.gems.fromBalance = true"
>
@@ -49,8 +49,8 @@
<button
class="btn"
:class="{
'btn-primary': !gift.gems.fromBalance,
'btn-secondary': gift.gems.fromBalance,
'btn-primary': !fromBal,
'btn-secondary': fromBal,
}"
@click="gift.gems.fromBalance = false"
>
@@ -120,40 +120,16 @@
>
{{ $t("send") }}
</button>
<div
<payments-buttons
v-else
class="payments-column mx-auto"
:class="{'payments-disabled': !gift.subscription.key && gift.gems.amount < 1}"
>
<button
class="purchase btn btn-primary payment-button payment-item"
:disabled="!gift.subscription.key && gift.gems.amount < 1"
@click="showStripe({gift, uuid: userReceivingGems._id, receiverName})"
>
<div
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<button
class="btn payment-item paypal-checkout payment-button"
:disabled="!gift.subscription.key && gift.gems.amount < 1"
@click="openPaypalGift({gift: gift, giftedTo: userReceivingGems._id, receiverName})"
>
&nbsp;
<img
src="~@/assets/images/paypal-checkout.png"
:alt="$t('paypal')"
>&nbsp;
</button>
<amazon-button
class="payment-item mb-0"
:stripe-fn="() => showStripe({gift, uuid: userReceivingGems._id, receiverName})"
:paypal-fn="() => openPaypalGift({
gift: gift, giftedTo: userReceivingGems._id, receiverName,
})"
:amazon-data="{type: 'single', gift, giftedTo: userReceivingGems._id, receiverName}"
:amazon-disabled="!gift.subscription.key && gift.gems.amount < 1"
/>
</div>
</div>
</b-modal>
</template>
@@ -194,15 +170,14 @@ import { mapState } from '@/libs/store';
import planGemLimits from '@/../../common/script/libs/planGemLimits';
import paymentsMixin from '@/mixins/payments';
import notificationsMixin from '@/mixins/notifications';
import amazonButton from '@/components/payments/amazonButton';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
import paymentsButtons from '@/components/payments/buttons/list';
// @TODO: EMAILS.TECH_ASSISTANCE_EMAIL, load from config
const TECH_ASSISTANCE_EMAIL = 'admin@habitica.com';
export default {
components: {
amazonButton,
paymentsButtons,
},
mixins: [paymentsMixin, notificationsMixin],
data () {
@@ -223,9 +198,6 @@ export default {
},
sendingInProgress: false,
userReceivingGems: null,
icons: Object.freeze({
creditCardIcon,
}),
};
},
computed: {

View File

@@ -33,7 +33,7 @@
class="svg-icon"
v-html="icons.gem"
></div>
<span>20</span>
<span>{{ paymentData.gemsBlock.gems }}</span>
</div>
</template>
<template

View File

@@ -25,36 +25,10 @@
</div>
</b-form-radio>
</b-form-group>
<div class="payments-column mx-auto mt-auto">
<button
class="purchase btn btn-primary payment-button payment-item"
:class="{disabled: !subscription.key}"
:disabled="!subscription.key"
@click="showStripe({subscription:subscription.key, coupon:subscription.coupon})"
>
<div
v-once
class="svg-icon credit-card-icon"
v-html="icons.creditCardIcon"
></div>
{{ $t('card') }}
</button>
<button
class="btn payment-item paypal-checkout payment-button"
:class="{disabled: !subscription.key}"
:disabled="!subscription.key"
@click="openPaypal(paypalPurchaseLink, 'subscription')"
>
&nbsp;
<img
src="~@/assets/images/paypal-checkout.png"
:alt="$t('paypal')"
>&nbsp;
</button>
<amazon-button
class="payment-item"
:class="{disabled: !subscription.key}"
<payments-buttons
:disabled="!subscription.key"
:stripe-fn="() => showStripe({subscription:subscription.key, coupon:subscription.coupon})"
:paypal-fn="() => openPaypal({url: paypalPurchaseLink, type: 'subscription'})"
:amazon-data="{
type: 'subscription',
subscription: subscription.key,
@@ -62,17 +36,12 @@
}"
/>
</div>
</div>
</template>
<style lang="scss">
@import '~@/assets/scss/colors.scss';
#subscription-form {
.disabled .amazonpay-button-inner-image {
cursor: default !important;
}
.custom-control .custom-control-label::before,
.custom-radio .custom-control-input:checked ~ .custom-control-label::after {
margin-top: 0.75rem;
@@ -110,15 +79,6 @@
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.disabled {
opacity: 0.64;
.btn, .btn:hover, .btn:active {
box-shadow: none;
cursor: default !important;
}
}
.subscribe-option {
border-bottom: 1px solid $gray-600;
}
@@ -128,14 +88,13 @@
import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy';
import amazonButton from '@/components/payments/amazonButton';
import creditCardIcon from '@/assets/svg/credit-card-icon.svg';
import paymentsButtons from '@/components/payments/buttons/list';
import paymentsMixin from '../../mixins/payments';
import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks';
export default {
components: {
amazonButton,
paymentsButtons,
},
mixins: [
paymentsMixin,
@@ -145,9 +104,6 @@ export default {
subscription: {
key: null,
},
icons: Object.freeze({
creditCardIcon,
}),
};
},
computed: {

View File

@@ -363,12 +363,6 @@
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.close-icon {
position: absolute;
top: 1rem;
right: 1rem;
}
.hourglass-nonsub {
color: $yellow-5;
font-size: 12px;

View File

@@ -12,11 +12,13 @@
calendar-class="calendar-padding"
@input="upDate($event)"
>
<div slot="afterDateInput"
class="vdp-datepicker__clear-button"
<div
v-if="clearButton && value"
slot="afterDateInput"
class="vdp-datepicker__clear-button"
@click="upDate(null)"
v-html="icons.close"
@click="upDate(null)">
>
</div>
<div slot="beforeCalendarHeader">
<div class="datetime-buttons">

View File

@@ -46,15 +46,26 @@ export default {
const encodedString = JSON.stringify(gift);
return encodeURIComponent(encodedString);
},
openPaypalGift (data) {
if (!this.checkGemAmount(data)) return;
openPaypalGift (giftData) {
if (!this.checkGemAmount(giftData)) return;
const gift = this.encodeGift(data.giftedTo, data.gift);
const gift = this.encodeGift(giftData.giftedTo, giftData.gift);
const url = `/paypal/checkout?gift=${gift}`;
this.openPaypal(url, `gift-${data.gift.type === 'gems' ? 'gems' : 'subscription'}`, data);
this.openPaypal({
url,
type: `gift-${giftData.gift.type === 'gems' ? 'gems' : 'subscription'}`,
giftData,
});
},
openPaypal (url, type, giftData) {
openPaypal (data = {}) {
const {
type,
giftData,
gemsBlock,
} = data;
let { url } = data;
const appState = {
paymentMethod: 'paypal',
paymentCompleted: false,
@@ -70,6 +81,11 @@ export default {
appState.giftReceiver = giftData.receiverName;
}
if (type === 'gems') {
appState.gemsBlock = gemsBlock;
url += `?gemsBlock=${gemsBlock.key}`;
}
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
window.open(url, '_blank');
@@ -97,7 +113,8 @@ export default {
sub = sub && subscriptionBlocks[sub];
let amount = 500; // 500 = $5
let amount;
if (data.gemsBlock) amount = data.gemsBlock.price;
if (sub) amount = sub.price * 100;
if (data.gift && data.gift.type === 'gems') amount = (data.gift.gems.amount / 4) * 100;
if (data.group) amount = (sub.price + 3 * (data.group.memberCount - 1)) * 100;
@@ -109,14 +126,18 @@ export default {
if (data.gift && data.gift.type === 'gems') paymentType = 'gift-gems';
if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription';
const label = (sub && paymentType !== 'gift-subscription')
? this.$t('subscribe')
: this.$t('checkout');
window.StripeCheckout.open({
key: STRIPE_PUB_KEY,
address: false,
amount,
name: 'Habitica',
description: sub ? this.$t('subscribe') : this.$t('checkout'),
description: label,
// image: '/apple-touch-icon-144-precomposed.png',
panelLabel: sub ? this.$t('subscribe') : this.$t('checkout'),
panelLabel: label,
token: async res => {
let url = '/stripe/checkout?a=a'; // just so I can concat &x=x below
@@ -126,6 +147,7 @@ export default {
res.paymentType = 'Stripe';
}
if (data.gemsBlock) url += `&gemsBlock=${data.gemsBlock.key}`;
if (data.gift) url += `&gift=${this.encodeGift(data.uuid, data.gift)}`;
if (data.subscription) url += `&sub=${sub.key}`;
if (data.coupon) url += `&coupon=${data.coupon}`;
@@ -160,6 +182,8 @@ export default {
} else if (paymentType.indexOf('gift-') === 0) {
appState.gift = data.gift;
appState.giftReceiver = data.receiverName;
} else if (paymentType === 'gems') {
appState.gemsBlock = data.gemsBlock;
}
@@ -233,6 +257,10 @@ export default {
amazonPaymentsInit (data) {
if (data.type !== 'single' && data.type !== 'subscription') return;
if (data.type === 'single') {
this.amazonPayments.gemsBlock = data.gemsBlock;
}
if (data.gift) {
if (data.gift.gems && data.gift.gems.amount && data.gift.gems.amount <= 0) return;
data.gift.uuid = data.giftedTo;

View File

@@ -193,13 +193,16 @@
#private-message {
height: calc(100vh - #{$menuToolbarHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)); // css variable magic :), must be 0px, 0 alone won't work
var(--banner-damage-paused-height, 0px) -
var(--banner-gems-promo-height, 0px)
); // css variable magic :), must be 0px, 0 alone won't work
.content {
flex: 1;
height: calc(100vh - #{$menuToolbarHeight} - #{$pmHeaderHeight} -
var(--banner-gifting-height, 0px) -
var(--banner-resting-height, 0px)
var(--banner-damage-paused-height, 0px) -
var(--banner-gems-promo-height, 0px)
);
}

View File

@@ -146,13 +146,14 @@
"couponCodeRequired": "The coupon code is required.",
"subCanceledTitle": "Subscription Canceled",
"choosePaymentMethod": "Choose your payment method",
"buyGemsSupportsDevs": "Purchasing Gems supports the developers and helps keep Habitica running",
"support": "SUPPORT",
"gemBenefitLeadin": "Gems allow you to buy fun extras for your account, including:",
"supportHabitica": "Support Habitica",
"gemBenefitLeadin": "What can you buy with Gems?",
"gemBenefit1": "Unique and fashionable costumes for your avatar.",
"gemBenefit2": "Backgrounds to immerse your avatar in the world of Habitica!",
"gemBenefit3": "Exciting Quest chains that drop pet eggs.",
"gemBenefit4": "Reset your avatar's Stat Points and change its Class.",
"usuallyGems": "Usually <%= originalGems %>",
"subscriptionBenefit1": "Alexander the Merchant will now sell you Gems from the Market for 20 Gold each!",
"subscriptionBenefit3": "Discover even more items in Habitica with a 2x daily drop-cap.",
"subscriptionBenefit4": "Unique cosmetic item for you to decorate your avatar each month.",
@@ -164,8 +165,6 @@
"subscribersReceiveBenefits": "Subscribers receive these useful benefits!",
"monthlyMysteryItems": "Monthly Mystery Items",
"doubleDropCap": "Double the Drops",
"subscriptionAlreadySubscribedLeadIn": "Thanks for subscribing!",
"gemsPurchaseNote": "Subscribers can buy gems for gold in the Market! For easy access, you can also pin the gem to your Rewards column.",
"youAreSubscribed": "You are subscribed to Habitica",
"subscriptionCanceled": "Your subscription is canceled",
"subscriptionInactiveDate": "Your subscription benefits will become inactive on <strong><%= date %></strong>",

View File

@@ -1,351 +0,0 @@
/* eslint-disable key-spacing */
import moment from 'moment';
export const CURRENT_SEASON = moment().isBefore('2020-08-02') ? 'summer' : '_NONE_';
export const CLASSES = [
'warrior',
'rogue',
'healer',
'wizard',
];
// IMPORTANT: The end date should be one to two days AFTER the actual end of
// the event, to allow people in different timezones to still buy the
// event gear up until at least the actual end of the event.
export const EVENTS = {
winter: { start: '2013-12-31', end: '2014-02-01' },
birthday: { start: '2017-01-31', end: '2017-02-02' },
spring: { start: '2014-03-21', end: '2014-05-01' },
summer: { start: '2014-06-20', end: '2014-08-01' },
fall: { start: '2014-09-21', end: '2014-11-01' },
winter2015: { start: '2014-12-21', end: '2015-02-02' },
spring2015: { start: '2015-03-20', end: '2015-05-02' },
summer2015: { start: '2015-06-20', end: '2015-08-02' },
fall2015: { start: '2015-09-21', end: '2015-11-01' },
gaymerx: { start: '2016-09-29', end: '2016-10-03' },
winter2016: { start: '2015-12-18', end: '2016-02-02' },
spring2016: { start: '2016-03-18', end: '2016-05-02' },
summer2016: { start: '2016-06-21', end: '2016-08-02' },
fall2016: { start: '2016-09-20', end: '2016-11-02' },
winter2017: { start: '2016-12-16', end: '2017-02-02' },
spring2017: { start: '2017-03-21', end: '2017-05-02' },
summer2017: { start: '2017-06-20', end: '2017-08-02' },
fall2017: { start: '2017-09-21', end: '2017-11-02' },
winter2018: { start: '2017-12-19', end: '2018-02-02' },
spring2018: { start: '2018-03-20', end: '2018-05-02' },
summer2018: { start: '2018-06-19', end: '2018-08-02' },
fall2018: { start: '2018-09-20', end: '2018-11-02' },
winter2019: { start: '2018-12-19', end: '2019-02-02' },
spring2019: { start: '2019-03-19', end: '2019-05-02' },
summer2019: { start: '2019-06-18', end: '2019-08-02' },
fall2019: { start: '2019-09-24', end: '2019-11-02' },
winter2020: { start: '2019-12-19', end: '2020-02-02' },
spring2020: { start: '2020-03-17', end: '2020-05-02' },
summer2020: { start: '2020-06-18', end: '2020-08-02' },
};
export const SEASONAL_SETS = {
winter: [
// winter 2014
'candycaneSet',
'skiSet',
'snowflakeSet',
'yetiSet',
// winter 2015
'northMageSet',
'icicleDrakeSet',
'soothingSkaterSet',
'gingerbreadSet',
// winter 2016
'snowDaySet',
'snowboardingSet',
'festiveFairySet',
'cocoaSet',
// winter 2017
'winter2017IceHockeySet',
'winter2017WinterWolfSet',
'winter2017SugarPlumSet',
'winter2017FrostyRogueSet',
// winter 2018
'winter2018ConfettiSet',
'winter2018GiftWrappedSet',
'winter2018MistletoeSet',
'winter2018ReindeerSet',
// winter 2019
'winter2019PoinsettiaSet',
'winter2019WinterStarSet',
'winter2019BlizzardSet',
'winter2019PyrotechnicSet',
// winter 2020
'winter2020CarolOfTheMageSet',
'winter2020LanternSet',
'winter2020EvergreenSet',
'winter2020WinterSpiceSet',
],
spring: [
// spring 2014
'mightyBunnySet',
'magicMouseSet',
'lovingPupSet',
'stealthyKittySet',
// spring 2015
'bewareDogSet',
'magicianBunnySet',
'comfortingKittySet',
'sneakySqueakerSet',
// spring 2016
'springingBunnySet',
'grandMalkinSet',
'cleverDogSet',
'braveMouseSet',
// spring 2017
'spring2017FelineWarriorSet',
'spring2017CanineConjurorSet',
'spring2017FloralMouseSet',
'spring2017SneakyBunnySet',
// spring 2018
'spring2018TulipMageSet',
'spring2018SunriseWarriorSet',
'spring2018DucklingRogueSet',
'spring2018GarnetHealerSet',
// spring 2019
'spring2019AmberMageSet',
'spring2019OrchidWarriorSet',
'spring2019CloudRogueSet',
'spring2019RobinHealerSet',
// spring 2020
'spring2020BeetleWarriorSet',
'spring2020IrisHealerSet',
'spring2020LapisLazuliRogueSet',
'spring2020PuddleMageSet',
],
summer: [
// summer 2014
'daringSwashbucklerSet',
'emeraldMermageSet',
'reefSeahealerSet',
'roguishPirateSet',
// summer 2015
'sunfishWarriorSet',
'shipSoothsayerSet',
'strappingSailorSet',
'reefRenegadeSet',
// summer 2016
'summer2016SharkWarriorSet',
'summer2016DolphinMageSet',
'summer2016SeahorseHealerSet',
'summer2016EelSet',
// summer 2017
'summer2017SandcastleWarriorSet',
'summer2017WhirlpoolMageSet',
'summer2017SeashellSeahealerSet',
'summer2017SeaDragonSet',
// summer 2018
'summer2018BettaFishWarriorSet',
'summer2018LionfishMageSet',
'summer2018MerfolkMonarchSet',
'summer2018FisherRogueSet',
// summer 2019
'summer2019SeaTurtleWarriorSet',
'summer2019WaterLilyMageSet',
'summer2019ConchHealerSet',
'summer2019HammerheadRogueSet',
// summer 2020
'summer2020SeaGlassHealerSet',
'summer2020OarfishMageSet',
'summer2020CrocodileRogueSet',
'summer2020RainbowTroutWarriorSet',
],
fall: [
// fall 2014
'vampireSmiterSet',
'monsterOfScienceSet',
'witchyWizardSet',
'mummyMedicSet',
// fall 2015
'battleRogueSet',
'scarecrowWarriorSet',
'stitchWitchSet',
'potionerSet',
// fall 2016
'fall2016BlackWidowSet',
'fall2016SwampThingSet',
'fall2016WickedSorcererSet',
'fall2016GorgonHealerSet',
// fall 2017
'fall2017TrickOrTreatSet',
'fall2017HabitoweenSet',
'fall2017MasqueradeSet',
'fall2017HauntedHouseSet',
// fall 2018
'fall2018MinotaurWarriorSet',
'fall2018CandymancerMageSet',
'fall2018CarnivorousPlantSet',
'fall2018AlterEgoSet',
// fall 2019
'fall2019CyclopsSet',
'fall2019LichSet',
'fall2019OperaticSpecterSet',
'fall2019RavenSet',
],
};
export const GEAR_TYPES = [
'weapon',
'armor',
'head',
'shield',
'body',
'back',
'headAccessory',
'eyewear',
];
export const ITEM_LIST = {
weapon: { localeKey: 'weapon', isEquipment: true },
armor: { localeKey: 'armor', isEquipment: true },
head: { localeKey: 'headgear', isEquipment: true },
shield: { localeKey: 'offhand', isEquipment: true },
back: { localeKey: 'back', isEquipment: true },
body: { localeKey: 'body', isEquipment: true },
headAccessory: { localeKey: 'headAccessory', isEquipment: true },
eyewear: { localeKey: 'eyewear', isEquipment: true },
hatchingPotions: { localeKey: 'hatchingPotion', isEquipment: false },
premiumHatchingPotions: { localeKey: 'hatchingPotion', isEquipment: false },
eggs: { localeKey: 'eggSingular', isEquipment: false },
quests: { localeKey: 'quest', isEquipment: false },
food: { localeKey: 'foodTextThe', isEquipment: false },
Saddle: { localeKey: 'foodSaddleText', isEquipment: false },
bundles: { localeKey: 'discountBundle', isEquipment: false },
};
export const USER_CAN_OWN_QUEST_CATEGORIES = [
'unlockable',
'gold',
'hatchingPotion',
'pet',
];
export const QUEST_SERIES_ACHIEVEMENTS = {
lostMasterclasser: [
'dilatoryDistress1',
'dilatoryDistress2',
'dilatoryDistress3',
'mayhemMistiflying1',
'mayhemMistiflying2',
'mayhemMistiflying3',
'stoikalmCalamity1',
'stoikalmCalamity2',
'stoikalmCalamity3',
'taskwoodsTerror1',
'taskwoodsTerror2',
'taskwoodsTerror3',
'lostMasterclasser1',
'lostMasterclasser2',
'lostMasterclasser3',
'lostMasterclasser4',
],
mindOverMatter: [
'rock',
'slime',
'yarn',
],
justAddWater: [
'octopus',
'dilatory_derby',
'kraken',
'whale',
'turtle',
'nudibranch',
'seaserpent',
'dolphin',
],
bugBonanza: [
'beetle',
'butterfly',
'snail',
'spider',
],
bareNecessities: [
'monkey',
'sloth',
'treeling',
],
freshwaterFriends: [
'axolotl',
'frog',
'hippo',
],
};
export const ANIMAL_COLOR_ACHIEVEMENTS = [
{
color: 'Base',
petAchievement: 'backToBasics',
petNotificationType: 'ACHIEVEMENT_BACK_TO_BASICS',
mountAchievement: 'allYourBase',
mountNotificationType: 'ACHIEVEMENT_ALL_YOUR_BASE',
},
{
color: 'Desert',
petAchievement: 'dustDevil',
petNotificationType: 'ACHIEVEMENT_DUST_DEVIL',
mountAchievement: 'aridAuthority',
mountNotificationType: 'ACHIEVEMENT_ARID_AUTHORITY',
},
{
color: 'Zombie',
petAchievement: 'monsterMagus',
petNotificationType: 'ACHIEVEMENT_MONSTER_MAGUS',
mountAchievement: 'undeadUndertaker',
mountNotificationType: 'ACHIEVEMENT_UNDEAD_UNDERTAKER',
},
{
color: 'White',
petAchievement: 'primedForPainting',
petNotificationType: 'ACHIEVEMENT_PRIMED_FOR_PAINTING',
mountAchievement: 'pearlyPro',
mountNotificationType: 'ACHIEVEMENT_PEARLY_PRO',
},
{
color: 'CottonCandyPink',
petAchievement: 'tickledPink',
petNotificationType: 'ACHIEVEMENT_TICKLED_PINK',
mountAchievement: 'rosyOutlook',
mountNotificationType: 'ACHIEVEMENT_ROSY_OUTLOOK',
},
{
color: 'Golden',
petAchievement: 'goodAsGold',
petNotificationType: 'ACHIEVEMENT_GOOD_AS_GOLD',
mountAchievement: 'allThatGlitters',
mountNotificationType: 'ACHIEVEMENT_ALL_THAT_GLITTERS',
},
];

View File

@@ -0,0 +1,46 @@
const ANIMAL_COLOR_ACHIEVEMENTS = [
{
color: 'Base',
petAchievement: 'backToBasics',
petNotificationType: 'ACHIEVEMENT_BACK_TO_BASICS',
mountAchievement: 'allYourBase',
mountNotificationType: 'ACHIEVEMENT_ALL_YOUR_BASE',
},
{
color: 'Desert',
petAchievement: 'dustDevil',
petNotificationType: 'ACHIEVEMENT_DUST_DEVIL',
mountAchievement: 'aridAuthority',
mountNotificationType: 'ACHIEVEMENT_ARID_AUTHORITY',
},
{
color: 'Zombie',
petAchievement: 'monsterMagus',
petNotificationType: 'ACHIEVEMENT_MONSTER_MAGUS',
mountAchievement: 'undeadUndertaker',
mountNotificationType: 'ACHIEVEMENT_UNDEAD_UNDERTAKER',
},
{
color: 'White',
petAchievement: 'primedForPainting',
petNotificationType: 'ACHIEVEMENT_PRIMED_FOR_PAINTING',
mountAchievement: 'pearlyPro',
mountNotificationType: 'ACHIEVEMENT_PEARLY_PRO',
},
{
color: 'CottonCandyPink',
petAchievement: 'tickledPink',
petNotificationType: 'ACHIEVEMENT_TICKLED_PINK',
mountAchievement: 'rosyOutlook',
mountNotificationType: 'ACHIEVEMENT_ROSY_OUTLOOK',
},
{
color: 'Golden',
petAchievement: 'goodAsGold',
petNotificationType: 'ACHIEVEMENT_GOOD_AS_GOLD',
mountAchievement: 'allThatGlitters',
mountNotificationType: 'ACHIEVEMENT_ALL_THAT_GLITTERS',
},
];
export default ANIMAL_COLOR_ACHIEVEMENTS;

View File

@@ -0,0 +1,58 @@
/* eslint-disable key-spacing */
// IMPORTANT: The end date should be one to two days AFTER the actual end of
// the event, to allow people in different timezones to still buy the
// event gear up until at least the actual end of the event.
// Unless we want a precise ending, for example during a gems sale.
// gem block: number of gems
const gemsPromo = {
'4gems': 5,
'21gems': 30,
'42gems': 60,
'84gems': 125,
};
export const EVENTS = {
fall2020SecondPromo: {
start: '2020-10-29T08:00-04:00',
end: '2020-11-02T20:00-05:00',
gemsPromo,
},
fall2020: {
start: '2020-09-22T08:00-04:00',
end: '2020-09-30T20:00-04:00',
gemsPromo,
},
// Dates from this point on (^) are in the RFC 2822 format, see https://momentjs.com/docs/#/parsing/string/
summer2020: { start: '2020-06-18', end: '2020-08-02' },
spring2020: { start: '2020-03-17', end: '2020-05-02' },
winter2020: { start: '2019-12-19', end: '2020-02-02' },
fall2019: { start: '2019-09-24', end: '2019-11-02' },
summer2019: { start: '2019-06-18', end: '2019-08-02' },
spring2019: { start: '2019-03-19', end: '2019-05-02' },
winter2019: { start: '2018-12-19', end: '2019-02-02' },
fall2018: { start: '2018-09-20', end: '2018-11-02' },
summer2018: { start: '2018-06-19', end: '2018-08-02' },
spring2018: { start: '2018-03-20', end: '2018-05-02' },
winter2018: { start: '2017-12-19', end: '2018-02-02' },
fall2017: { start: '2017-09-21', end: '2017-11-02' },
summer2017: { start: '2017-06-20', end: '2017-08-02' },
spring2017: { start: '2017-03-21', end: '2017-05-02' },
winter2017: { start: '2016-12-16', end: '2017-02-02' },
fall2016: { start: '2016-09-20', end: '2016-11-02' },
summer2016: { start: '2016-06-21', end: '2016-08-02' },
spring2016: { start: '2016-03-18', end: '2016-05-02' },
winter2016: { start: '2015-12-18', end: '2016-02-02' },
gaymerx: { start: '2016-09-29', end: '2016-10-03' },
fall2015: { start: '2015-09-21', end: '2015-11-01' },
summer2015: { start: '2015-06-20', end: '2015-08-02' },
spring2015: { start: '2015-03-20', end: '2015-05-02' },
winter2015: { start: '2014-12-21', end: '2015-02-02' },
fall: { start: '2014-09-21', end: '2014-11-01' },
summer: { start: '2014-06-20', end: '2014-08-01' },
spring: { start: '2014-03-21', end: '2014-05-01' },
birthday: { start: '2017-01-31', end: '2017-02-02' },
winter: { start: '2013-12-31', end: '2014-02-01' },
};

View File

@@ -0,0 +1,34 @@
import moment from 'moment';
export const CURRENT_SEASON = moment().isBefore('2020-08-02') ? 'summer' : '_NONE_';
export const CLASSES = [
'warrior',
'rogue',
'healer',
'wizard',
];
export const GEAR_TYPES = [
'weapon',
'armor',
'head',
'shield',
'body',
'back',
'headAccessory',
'eyewear',
];
export const USER_CAN_OWN_QUEST_CATEGORIES = [
'unlockable',
'gold',
'hatchingPotion',
'pet',
];
export { EVENTS } from './events';
export { default as SEASONAL_SETS } from './seasonalSets';
export { default as ANIMAL_COLOR_ACHIEVEMENTS } from './animalColorAchievements';
export { default as QUEST_SERIES_ACHIEVEMENTS } from './questSeriesAchievements';
export { default as ITEM_LIST } from './itemList';

View File

@@ -0,0 +1,21 @@
/* eslint-disable key-spacing */
const ITEM_LIST = {
weapon: { localeKey: 'weapon', isEquipment: true },
armor: { localeKey: 'armor', isEquipment: true },
head: { localeKey: 'headgear', isEquipment: true },
shield: { localeKey: 'offhand', isEquipment: true },
back: { localeKey: 'back', isEquipment: true },
body: { localeKey: 'body', isEquipment: true },
headAccessory: { localeKey: 'headAccessory', isEquipment: true },
eyewear: { localeKey: 'eyewear', isEquipment: true },
hatchingPotions: { localeKey: 'hatchingPotion', isEquipment: false },
premiumHatchingPotions: { localeKey: 'hatchingPotion', isEquipment: false },
eggs: { localeKey: 'eggSingular', isEquipment: false },
quests: { localeKey: 'quest', isEquipment: false },
food: { localeKey: 'foodTextThe', isEquipment: false },
Saddle: { localeKey: 'foodSaddleText', isEquipment: false },
bundles: { localeKey: 'discountBundle', isEquipment: false },
};
export default ITEM_LIST;

View File

@@ -0,0 +1,53 @@
const QUEST_SERIES_ACHIEVEMENTS = {
lostMasterclasser: [
'dilatoryDistress1',
'dilatoryDistress2',
'dilatoryDistress3',
'mayhemMistiflying1',
'mayhemMistiflying2',
'mayhemMistiflying3',
'stoikalmCalamity1',
'stoikalmCalamity2',
'stoikalmCalamity3',
'taskwoodsTerror1',
'taskwoodsTerror2',
'taskwoodsTerror3',
'lostMasterclasser1',
'lostMasterclasser2',
'lostMasterclasser3',
'lostMasterclasser4',
],
mindOverMatter: [
'rock',
'slime',
'yarn',
],
justAddWater: [
'octopus',
'dilatory_derby',
'kraken',
'whale',
'turtle',
'nudibranch',
'seaserpent',
'dolphin',
],
bugBonanza: [
'beetle',
'butterfly',
'snail',
'spider',
],
bareNecessities: [
'monkey',
'sloth',
'treeling',
],
freshwaterFriends: [
'axolotl',
'frog',
'hippo',
],
};
export default QUEST_SERIES_ACHIEVEMENTS;

View File

@@ -0,0 +1,171 @@
const SEASONAL_SETS = {
winter: [
// winter 2014
'candycaneSet',
'skiSet',
'snowflakeSet',
'yetiSet',
// winter 2015
'northMageSet',
'icicleDrakeSet',
'soothingSkaterSet',
'gingerbreadSet',
// winter 2016
'snowDaySet',
'snowboardingSet',
'festiveFairySet',
'cocoaSet',
// winter 2017
'winter2017IceHockeySet',
'winter2017WinterWolfSet',
'winter2017SugarPlumSet',
'winter2017FrostyRogueSet',
// winter 2018
'winter2018ConfettiSet',
'winter2018GiftWrappedSet',
'winter2018MistletoeSet',
'winter2018ReindeerSet',
// winter 2019
'winter2019PoinsettiaSet',
'winter2019WinterStarSet',
'winter2019BlizzardSet',
'winter2019PyrotechnicSet',
// winter 2020
'winter2020CarolOfTheMageSet',
'winter2020LanternSet',
'winter2020EvergreenSet',
'winter2020WinterSpiceSet',
],
spring: [
// spring 2014
'mightyBunnySet',
'magicMouseSet',
'lovingPupSet',
'stealthyKittySet',
// spring 2015
'bewareDogSet',
'magicianBunnySet',
'comfortingKittySet',
'sneakySqueakerSet',
// spring 2016
'springingBunnySet',
'grandMalkinSet',
'cleverDogSet',
'braveMouseSet',
// spring 2017
'spring2017FelineWarriorSet',
'spring2017CanineConjurorSet',
'spring2017FloralMouseSet',
'spring2017SneakyBunnySet',
// spring 2018
'spring2018TulipMageSet',
'spring2018SunriseWarriorSet',
'spring2018DucklingRogueSet',
'spring2018GarnetHealerSet',
// spring 2019
'spring2019AmberMageSet',
'spring2019OrchidWarriorSet',
'spring2019CloudRogueSet',
'spring2019RobinHealerSet',
// spring 2020
'spring2020BeetleWarriorSet',
'spring2020IrisHealerSet',
'spring2020LapisLazuliRogueSet',
'spring2020PuddleMageSet',
],
summer: [
// summer 2014
'daringSwashbucklerSet',
'emeraldMermageSet',
'reefSeahealerSet',
'roguishPirateSet',
// summer 2015
'sunfishWarriorSet',
'shipSoothsayerSet',
'strappingSailorSet',
'reefRenegadeSet',
// summer 2016
'summer2016SharkWarriorSet',
'summer2016DolphinMageSet',
'summer2016SeahorseHealerSet',
'summer2016EelSet',
// summer 2017
'summer2017SandcastleWarriorSet',
'summer2017WhirlpoolMageSet',
'summer2017SeashellSeahealerSet',
'summer2017SeaDragonSet',
// summer 2018
'summer2018BettaFishWarriorSet',
'summer2018LionfishMageSet',
'summer2018MerfolkMonarchSet',
'summer2018FisherRogueSet',
// summer 2019
'summer2019SeaTurtleWarriorSet',
'summer2019WaterLilyMageSet',
'summer2019ConchHealerSet',
'summer2019HammerheadRogueSet',
// summer 2020
'summer2020SeaGlassHealerSet',
'summer2020OarfishMageSet',
'summer2020CrocodileRogueSet',
'summer2020RainbowTroutWarriorSet',
],
fall: [
// fall 2014
'vampireSmiterSet',
'monsterOfScienceSet',
'witchyWizardSet',
'mummyMedicSet',
// fall 2015
'battleRogueSet',
'scarecrowWarriorSet',
'stitchWitchSet',
'potionerSet',
// fall 2016
'fall2016BlackWidowSet',
'fall2016SwampThingSet',
'fall2016WickedSorcererSet',
'fall2016GorgonHealerSet',
// fall 2017
'fall2017TrickOrTreatSet',
'fall2017HabitoweenSet',
'fall2017MasqueradeSet',
'fall2017HauntedHouseSet',
// fall 2018
'fall2018MinotaurWarriorSet',
'fall2018CandymancerMageSet',
'fall2018CarnivorousPlantSet',
'fall2018AlterEgoSet',
// fall 2019
'fall2019CyclopsSet',
'fall2019LichSet',
'fall2019OperaticSpecterSet',
'fall2019RavenSet',
],
};
export default SEASONAL_SETS;

View File

@@ -0,0 +1,40 @@
const blocks = {
'4gems': {
gems: 4,
iosProducts: ['com.habitrpg.ios.Habitica.4gems'],
androidProducts: ['com.habitrpg.android.habitica.iap.4gems'],
price: 99, // in cents, web only
},
'21gems': {
gems: 21,
iosProducts: [
'com.habitrpg.ios.Habitica.20gems',
'com.habitrpg.ios.Habitica.21gems',
],
androidProducts: [
'com.habitrpg.android.habitica.iap.20.gems',
'com.habitrpg.android.habitica.iap.21.gems',
],
price: 499, // in cents, web only
},
'42gems': {
gems: 42,
iosProducts: ['com.habitrpg.ios.Habitica.42gems'],
androidProducts: ['com.habitrpg.android.habitica.iap.42gems'],
price: 999, // in cents, web only
},
'84gems': {
gems: 84,
iosProducts: ['com.habitrpg.ios.Habitica.84gems'],
androidProducts: ['com.habitrpg.android.habitica.iap.84gems'],
price: 1999, // in cents, web only
},
};
// Add the block key to all blocks
Object.keys(blocks).forEach(blockKey => {
const block = blocks[blockKey];
block.key = blockKey;
});
export default blocks;

View File

@@ -5,6 +5,7 @@ import t from './translation';
import { tasksByCategory } from './tasks';
import {
EVENTS,
CLASSES,
GEAR_TYPES,
ITEM_LIST,
@@ -29,6 +30,7 @@ import { backgroundsTree, backgroundsFlat } from './appearance/backgrounds';
import bundles from './bundles';
import spells from './spells'; // eslint-disable-line import/no-cycle
import subscriptionBlocks from './subscriptionBlocks';
import gemsBlock from './gems';
import faq from './faq';
import timeTravelers from './time-travelers';
@@ -51,6 +53,7 @@ api.itemList = ITEM_LIST;
api.gear = gear;
api.spells = spells;
api.subscriptionBlocks = subscriptionBlocks;
api.gems = gemsBlock;
api.audioThemes = ['danielTheBard', 'gokulTheme', 'luneFoxTheme', 'wattsTheme', 'rosstavoTheme', 'dewinTheme', 'airuTheme', 'beatscribeNesTheme', 'arashiTheme', 'maflTheme', 'pizildenTheme', 'farvoidTheme', 'spacePenguinTheme', 'lunasolTheme', 'triumphTheme'];
@@ -91,20 +94,10 @@ api.armoire = {
},
};
/*
---------------------------------------------------------------
Classes
---------------------------------------------------------------
*/
api.events = EVENTS;
api.classes = CLASSES;
/*
---------------------------------------------------------------
Gear Types
---------------------------------------------------------------
*/
api.gearTypes = GEAR_TYPES;
api.cardTypes = {
@@ -192,7 +185,6 @@ api.specialMounts = stable.specialMounts;
api.mountInfo = stable.mountInfo;
// For seasonal events, change this constant:
const FOOD_SEASON = 'Normal';
api.food = {

View File

@@ -25,6 +25,7 @@ export default {
missingCustomerId: 'Missing "req.query.customerId"',
missingPaypalBlock: 'Missing "req.session.paypalBlock"',
missingSubKey: 'Missing "req.query.sub"',
invalidGemsBlock: 'The supplied gemsBlock does not exists',
ipAddressBlocked: 'Your access to Habitica has been blocked. This may be due to a breach of our Terms of Service or for other reasons. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @Username or User Id in the email if you know it.',
clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools under the section Rate Limiting.',

View File

@@ -1,21 +1,10 @@
import {
model as Group,
TAVERN_ID as tavernId,
} from '../../models/group';
getCurrentEvent,
getWorldBoss,
} from '../../libs/worldState';
const api = {};
async function getWorldBoss () {
const tavern = await Group
.findById(tavernId)
.select('quest.progress quest.key quest.active quest.extra')
.exec();
if (tavern && tavern.quest && tavern.quest.active) {
return tavern.quest;
}
return {};
}
/**
* @api {get} /api/v3/world-state Get the state for the game world
* @apiDescription Does not require authentication.
@@ -30,6 +19,7 @@ async function getWorldBoss () {
* @apiSuccess {Object} data.worldBoss.progress.hp Number, Current Health of the world boss
* @apiSuccess {Object} data.worldBoss.progress.rage Number, Current Rage of the world boss
* @apiSuccess {Object} data.npcImageSuffix String, trailing component of NPC image filenames
* @apiSuccess {Object} data.currentEvent The current active event
*
*/
api.getWorldState = {
@@ -41,6 +31,8 @@ api.getWorldState = {
worldState.worldBoss = await getWorldBoss();
worldState.npcImageSuffix = 'spring';
worldState.currentEvent = getCurrentEvent();
res.respond(200, worldState);
},
};

View File

@@ -74,14 +74,13 @@ api.checkout = {
url: '/amazon/checkout',
middlewares: [authWithHeaders()],
async handler (req, res) {
const { gift } = req.body;
const { user } = res.locals;
const { orderReferenceId } = req.body;
const { orderReferenceId, gift, gemsBlock } = req.body;
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
await amzLib.checkout({
gift, user, orderReferenceId, headers: req.headers,
gemsBlock, gift, user, orderReferenceId, headers: req.headers,
});
res.respond(200);

View File

@@ -27,7 +27,10 @@ api.checkout = {
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
req.session.gift = req.query.gift;
const link = await paypalPayments.checkout({ gift, user: res.locals.user });
const { gemsBlock } = req.query;
req.session.gemsBlock = gemsBlock;
const link = await paypalPayments.checkout({ gift, gemsBlock, user: res.locals.user });
if (req.query.noRedirect) {
res.respond(200);
@@ -53,12 +56,14 @@ api.checkoutSuccess = {
const { user } = res.locals;
const gift = req.session.gift ? JSON.parse(req.session.gift) : undefined;
delete req.session.gift;
const { gemsBlock } = req.session;
delete req.session.gemsBlock;
if (!paymentId) throw new BadRequest(apiError('missingPaymentId'));
if (!customerId) throw new BadRequest(apiError('missingCustomerId'));
await paypalPayments.checkoutSuccess({
user, gift, paymentId, customerId,
user, gemsBlock, gift, paymentId, customerId,
});
if (req.query.noRedirect) {

View File

@@ -32,11 +32,10 @@ api.checkout = {
const { user } = res.locals;
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
const sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
const { groupId } = req.query;
const { coupon } = req.query;
const { groupId, coupon, gemsBlock } = req.query;
await stripePayments.checkout({
token, user, gift, sub, groupId, coupon,
token, user, gemsBlock, gift, sub, groupId, coupon,
});
res.respond(200, {});

View File

@@ -17,6 +17,7 @@ import { // eslint-disable-line import/no-cycle
basicFields as basicGroupFields,
} from '../../models/group';
import { model as Coupon } from '../../models/coupon';
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
// TODO better handling of errors
@@ -109,9 +110,10 @@ api.authorize = function authorize (inputSet) {
*/
api.checkout = async function checkout (options = {}) {
const {
gift, user, orderReferenceId, headers,
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
} = options;
let amount = 5;
let amount;
let gemsBlock;
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
@@ -124,6 +126,9 @@ api.checkout = async function checkout (options = {}) {
} else if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) {
amount = common.content.subscriptionBlocks[gift.subscription.key].price;
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
}
if (!gift || gift.type === this.constants.GIFT_TYPE_GEMS) {
@@ -170,6 +175,7 @@ api.checkout = async function checkout (options = {}) {
user,
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
gemsBlock,
};
if (gift) {

View File

@@ -2,6 +2,7 @@ import moment from 'moment';
import shared from '../../../common';
import iap from '../inAppPurchases';
import payments from './payments';
import { getGemsBlock } from './gems';
import {
NotAuthorized,
BadRequest,
@@ -29,6 +30,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
}
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language));
@@ -59,28 +61,38 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let amount;
let gemsBlockKey;
switch (purchaseData.productId) { // eslint-disable-line default-case
case 'com.habitrpg.ios.Habitica.4gems':
amount = 1;
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
amount = 5.25;
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.ios.Habitica.42gems':
amount = 10.5;
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.ios.Habitica.84gems':
amount = 21;
gemsBlockKey = '84gems';
break;
}
if (amount) {
if (!gemsBlockKey) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
if (gemsBlock) {
correctReceipt = true;
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user: receiver,
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
amount,
gemsBlock,
headers,
});
}

View File

@@ -1,10 +1,15 @@
import * as analytics from '../analyticsService';
import { getCurrentEvent } from '../worldState'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
getUserInfo,
sendTxn as txnEmail,
} from '../email';
import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle
import shared from '../../../common';
import {
BadRequest,
} from '../errors';
import apiError from '../apiError';
function getGiftMessage (data, byUsername, gemAmount, language) {
const senderMsg = shared.i18n.t('giftedGemsFull', {
@@ -55,12 +60,25 @@ async function buyGemGift (data) {
await data.gift.member.save();
}
function getAmountForGems (data) {
const amount = data.amount || 5;
export function getGemsBlock (gemsBlock) {
const block = shared.content.gems[gemsBlock];
if (!block) throw new BadRequest(apiError('invalidGemsBlock'));
return block;
}
function getAmountForGems (data) {
if (data.gift) return data.gift.gems.amount / 4;
return amount;
const { gemsBlock } = data;
const currentEvent = getCurrentEvent();
if (currentEvent && currentEvent.gemsPromo && currentEvent.gemsPromo[gemsBlock.key]) {
return currentEvent.gemsPromo[gemsBlock.key] / 4;
}
return gemsBlock.gems / 4;
}
function updateUserBalance (data, amount) {
@@ -72,7 +90,7 @@ function updateUserBalance (data, amount) {
data.user.balance += amount;
}
async function buyGems (data) {
export async function buyGems (data) {
const amt = getAmountForGems(data);
updateUserBalance(data, amt);
@@ -96,5 +114,3 @@ async function buyGems (data) {
await data.user.save();
}
export { buyGems }; // eslint-disable-line import/prefer-default-export

View File

@@ -8,6 +8,7 @@ import {
} from '../errors';
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
import { model as User } from '../../models/user';
import { getGemsBlock } from './gems';
const api = {};
@@ -59,30 +60,39 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let amount;
let gemsBlockKey;
switch (receiptObj.productId) { // eslint-disable-line default-case
case 'com.habitrpg.android.habitica.iap.4gems':
amount = 1;
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.android.habitica.iap.20.gems':
case 'com.habitrpg.android.habitica.iap.20gems':
case 'com.habitrpg.android.habitica.iap.21gems':
amount = 5.25;
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.android.habitica.iap.42gems':
amount = 10.5;
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.android.habitica.iap.84gems':
amount = 21;
gemsBlockKey = '84gems';
break;
}
if (!amount) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
if (!gemsBlockKey) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
await payments.buyGems({
user: receiver,
user,
gift,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
amount,
gemsBlock,
headers,
});

View File

@@ -8,6 +8,7 @@ import paypal from 'paypal-rest-sdk';
import cc from 'coupon-code';
import shared from '../../../common';
import payments from './payments'; // eslint-disable-line import/no-cycle
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
import { model as Coupon } from '../../models/coupon';
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
@@ -76,9 +77,10 @@ api.paypalBillingAgreementCancel = util
api.ipnVerifyAsync = util.promisify(paypalIpn.verify.bind(paypalIpn));
api.checkout = async function checkout (options = {}) {
const { gift, user } = options;
const { gift, user, gemsBlock: gemsBlockKey } = options;
let amount = 5.00;
let amount;
let gemsBlock;
let description = 'Habitica Gems';
if (gift) {
@@ -95,6 +97,9 @@ api.checkout = async function checkout (options = {}) {
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
description = 'mo. Habitica Subscription (Gift)';
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
}
if (!gift || gift.type === 'gems') {
@@ -139,7 +144,7 @@ api.checkout = async function checkout (options = {}) {
api.checkoutSuccess = async function checkoutSuccess (options = {}) {
const {
user, gift, paymentId, customerId,
user, gift, gemsBlock: gemsBlockKey, paymentId, customerId,
} = options;
let method = 'buyGems';
@@ -157,6 +162,8 @@ api.checkoutSuccess = async function checkoutSuccess (options = {}) {
data.paymentMethod = 'PayPal (Gift)';
data.gift = gift;
} else {
data.gemsBlock = getGemsBlock(gemsBlockKey);
}
await this.paypalPaymentExecute(paymentId, { payer_id: customerId });

View File

@@ -13,6 +13,7 @@ import {
NotAuthorized,
} from '../../errors';
import payments from '../payments'; // eslint-disable-line import/no-cycle
import { getGemsBlock } from '../gems'; // eslint-disable-line import/no-cycle
import stripeConstants from './constants';
function getGiftAmount (gift) {
@@ -27,10 +28,14 @@ function getGiftAmount (gift) {
return `${(gift.gems.amount / 4) * 100}`;
}
async function buyGems (gift, user, token, stripeApi) {
let amount = 500; // $5
async function buyGems (gemsBlock, gift, user, token, stripeApi) {
let amount;
if (gift) amount = getGiftAmount(gift);
if (gift) {
amount = getGiftAmount(gift);
} else {
amount = gemsBlock.price;
}
if (!gift || gift.type === 'gems') {
const receiver = gift ? gift.member : user;
@@ -80,12 +85,13 @@ async function buySubscription (sub, coupon, email, user, token, groupId, stripe
return { subResponse: response, subId: subscriptionId };
}
async function applyGemPayment (user, response, gift) {
async function applyGemPayment (user, response, gemsBlock, gift) {
let method = 'buyGems';
const data = {
user,
customerId: response.id,
paymentMethod: stripeConstants.PAYMENT_METHOD,
gemsBlock,
gift,
};
@@ -97,11 +103,12 @@ async function applyGemPayment (user, response, gift) {
await payments[method](data);
}
async function checkout (options, stripeInc) {
export async function checkout (options, stripeInc) {
const {
token,
user,
gift,
gemsBlock,
sub,
groupId,
email,
@@ -123,6 +130,11 @@ async function checkout (options, stripeInc) {
gift.member = member;
}
let block;
if (!sub && !gift) {
block = getGemsBlock(gemsBlock);
}
if (sub) {
const { subId, subResponse } = await buySubscription(
sub, coupon, email, user, token, groupId, stripeApi,
@@ -130,7 +142,7 @@ async function checkout (options, stripeInc) {
subscriptionId = subId;
response = subResponse;
} else {
response = await buyGems(gift, user, token, stripeApi);
response = await buyGems(block, gift, user, token, stripeApi);
}
if (sub) {
@@ -143,10 +155,7 @@ async function checkout (options, stripeInc) {
groupId,
subscriptionId,
});
return;
} else {
await applyGemPayment(user, response, block, gift);
}
await applyGemPayment(user, response, gift);
}
export { checkout }; // eslint-disable-line import/prefer-default-export

View File

@@ -0,0 +1,33 @@
import moment from 'moment';
import { // eslint-disable-line import/no-cycle
model as Group,
TAVERN_ID as tavernId,
} from '../models/group';
import common from '../../common';
export async function getWorldBoss () {
const tavern = await Group
.findById(tavernId)
.select('quest.progress quest.key quest.active quest.extra')
.exec();
if (tavern && tavern.quest && tavern.quest.active) {
return tavern.quest;
}
return {};
}
export function getCurrentEvent () {
const currEvtKey = Object.keys(common.content.events).find(evtKey => {
const event = common.content.events[evtKey];
const now = moment();
return now.isBetween(event.start, event.end);
});
if (!currEvtKey) return null;
return {
event: currEvtKey,
...common.content.events[currEvtKey],
};
}