diff --git a/test/api/unit/middlewares/cronMiddleware.js b/test/api/unit/middlewares/cronMiddleware.js index 873bbb70a7..6e2ab0e18a 100644 --- a/test/api/unit/middlewares/cronMiddleware.js +++ b/test/api/unit/middlewares/cronMiddleware.js @@ -293,4 +293,107 @@ describe('cron middleware', () => { }); }); }); + + context('Drop Cap A/B Test', async () => { + it('enrolls web users', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-web'; + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.be.a.string; + + return resolve(); + }); + }); + }); + + it('does not enroll 80% of users', async () => { + sandbox.stub(Math, 'random').returns(0.5); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-web'; + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.be.equal('drop-cap-notif-not-enrolled'); + + return resolve(); + }); + }); + }); + + it('enables the new notification for 10% of users', async () => { + sandbox.stub(Math, 'random').returns(0.1); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-web'; + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.be.equal('drop-cap-notif-enabled'); + + return resolve(); + }); + }); + }); + + it('disables the new notification for 10% of users', async () => { + sandbox.stub(Math, 'random').returns(0.2); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-web'; + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.be.equal('drop-cap-notif-disabled'); + + return resolve(); + }); + }); + }); + + it('does not affect subscribers', async () => { + sandbox.stub(Math, 'random').returns(0.2); + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-web'; + sandbox.stub(User.prototype, 'isSubscribed').returns(true); + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.not.exist; + + return resolve(); + }); + }); + }); + + it('does not affect mobile users', async () => { + user.lastCron = moment(new Date()).subtract({ days: 2 }); + await user.save(); + req.headers['x-client'] = 'habitica-ios'; + + await new Promise((resolve, reject) => { + cronMiddleware(req, res, async err => { + if (err) return reject(err); + user = await User.findById(user._id).exec(); + expect(user._ABtests.dropCapNotif).to.not.exist; + + return resolve(); + }); + }); + }); + }); }); diff --git a/test/common/fns/randomDrop.test.js b/test/common/fns/randomDrop.test.js index 3ce03286b1..440bb4506d 100644 --- a/test/common/fns/randomDrop.test.js +++ b/test/common/fns/randomDrop.test.js @@ -1,4 +1,5 @@ import randomDrop from '../../../website/common/script/fns/randomDrop'; +import i18n from '../../../website/common/script/i18n'; import { generateUser, generateTodo, @@ -144,5 +145,148 @@ describe('common.fns.randomDrop', () => { expect(acceptableDrops).to.contain(user._tmp.drop.key); // always Desert }); }); + + context('drop cap notification', () => { + let analytics; + const req = {}; + let isSubscribedStub; + + beforeEach(() => { + user.addNotification = () => {}; + sandbox.stub(user, 'addNotification'); + user.isSubscribed = () => {}; + isSubscribedStub = sandbox.stub(user, 'isSubscribed'); + isSubscribedStub.returns(false); + analytics = { track () {} }; + sandbox.stub(analytics, 'track'); + }); + + it('sends a notification if A/B test is enabled when drop cap is reached', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-enabled'; + predictableRandom.returns(0.1); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(user.addNotification).to.be.calledOnce; + expect(user.addNotification).to.be.calledWith('DROP_CAP_REACHED', { + message: i18n.t('dropCapReached'), + items: 5, + }); + }); + + it('does not send a notification if user is enrolled in disabled A/B test group', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-disabled'; + predictableRandom.returns(0.1); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(user.addNotification).to.not.be.called; + }); + + it('does not send a notification if user is enrolled in disabled A/B test group', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-not-enrolled'; + predictableRandom.returns(0.1); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(user.addNotification).to.not.be.called; + }); + + it('does not send a notification if drop cap is not reached', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-enabled'; + predictableRandom.returns(0.1); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(4); + expect(user.addNotification).to.not.be.called; + }); + + it('does not send a notification if user is subscribed', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-enabled'; + predictableRandom.returns(0.1); + isSubscribedStub.returns(true); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(user.addNotification).to.not.be.called; + }); + + it('tracks drop cap reached event for enrolled users (notification enabled)', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-enabled'; + predictableRandom.returns(0.1); + isSubscribedStub.returns(true); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(analytics.track).to.be.calledWith('drop cap reached'); + }); + + it('tracks drop cap reached event for enrolled users (notification disabled)', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-disabled'; + predictableRandom.returns(0.1); + isSubscribedStub.returns(true); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(analytics.track).to.be.calledWith('drop cap reached'); + }); + + it('does not track drop cap reached event for users not enrolled in A/B test', () => { + user._ABtests.dropCapNotif = 'drop-cap-notif-not-enrolled'; + predictableRandom.returns(0.1); + isSubscribedStub.returns(true); + + // Max Drop Count is 5 + expect(user.items.lastDrop.count).to.equal(0); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + randomDrop(user, { task, predictableRandom }, req, analytics); + expect(user.items.lastDrop.count).to.equal(5); + expect(analytics.track).to.not.be.calledWith('drop cap reached'); + }); + }); }); }); diff --git a/website/client/src/assets/images/swords.png b/website/client/src/assets/images/swords.png new file mode 100644 index 0000000000..81f057ec38 Binary files /dev/null and b/website/client/src/assets/images/swords.png differ diff --git a/website/client/src/assets/images/swords@2x.png b/website/client/src/assets/images/swords@2x.png new file mode 100644 index 0000000000..5f8b2a86f4 Binary files /dev/null and b/website/client/src/assets/images/swords@2x.png differ diff --git a/website/client/src/assets/images/swords@3x.png b/website/client/src/assets/images/swords@3x.png new file mode 100644 index 0000000000..3bca6d2326 Binary files /dev/null and b/website/client/src/assets/images/swords@3x.png differ diff --git a/website/client/src/assets/scss/dropdown.scss b/website/client/src/assets/scss/dropdown.scss index 4a078edd25..0f810febcb 100644 --- a/website/client/src/assets/scss/dropdown.scss +++ b/website/client/src/assets/scss/dropdown.scss @@ -44,7 +44,7 @@ font-size: 14px; line-height: 1.71; - color: $gray-50; + color: $gray-50 !important; cursor: pointer; &:focus { @@ -54,16 +54,16 @@ &:active, &:hover, &:focus, &.active { - background-color: rgba($purple-600, 0.32); - color: $purple-200; + background-color: rgba($purple-600, 0.32) !important; + color: $purple-200 !important; } &.dropdown-inactive { cursor: default; &:active, &:hover, &.active { - background-color: inherit; - color: inherit; + background-color: inherit !important; + color: inherit !important; } } } diff --git a/website/client/src/assets/scss/modal.scss b/website/client/src/assets/scss/modal.scss index 15fae725c4..b9d1d51e27 100644 --- a/website/client/src/assets/scss/modal.scss +++ b/website/client/src/assets/scss/modal.scss @@ -9,6 +9,11 @@ border-radius: 8px; } +.modal-footer { + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; +} + .modal-dialog { margin: 3rem auto 3rem; width: auto; diff --git a/website/client/src/components/achievements/dropCapReached.vue b/website/client/src/components/achievements/dropCapReached.vue new file mode 100644 index 0000000000..e5f592e8fc --- /dev/null +++ b/website/client/src/components/achievements/dropCapReached.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/website/client/src/components/categories/categoryTags.vue b/website/client/src/components/categories/categoryTags.vue index f49bc7985e..6878185b52 100644 --- a/website/client/src/components/categories/categoryTags.vue +++ b/website/client/src/components/categories/categoryTags.vue @@ -22,12 +22,15 @@ export default { props: { categories: { + type: Array, required: true, }, owner: { + type: Boolean, default: false, }, member: { + type: Boolean, default: false, }, }, diff --git a/website/client/src/components/header/messageCount.vue b/website/client/src/components/header/messageCount.vue index f63685ba3b..90cea5d5e6 100644 --- a/website/client/src/components/header/messageCount.vue +++ b/website/client/src/components/header/messageCount.vue @@ -10,7 +10,7 @@ @import '~@/assets/scss/colors.scss'; .message-count { - background-color: $blue-50; + background-color: $red-50; border-radius: 50%; height: 20px; width: 20px; @@ -31,7 +31,6 @@ right: 0.3em; top: -0.8em; padding: 0.2em; - background-color: $red-50; } .message-count.top-count-gray { diff --git a/website/client/src/components/header/notifications/base.vue b/website/client/src/components/header/notifications/base.vue index e1e79896c7..1a0c03b9c1 100644 --- a/website/client/src/components/header/notifications/base.vue +++ b/website/client/src/components/header/notifications/base.vue @@ -118,11 +118,11 @@ } .notification-remove { - position: absolute; - width: 18px; - height: 18px; - padding: 4px; - right: 24px; + position: relative; + width: 10px; + height: 10px; + right: 0px; + top: 10.5px; .svg-icon { width: 10px; diff --git a/website/client/src/components/header/notifications/dropCapReached.vue b/website/client/src/components/header/notifications/dropCapReached.vue new file mode 100644 index 0000000000..34a133b64e --- /dev/null +++ b/website/client/src/components/header/notifications/dropCapReached.vue @@ -0,0 +1,43 @@ + + + diff --git a/website/client/src/components/header/notificationsDropdown.vue b/website/client/src/components/header/notificationsDropdown.vue index a8e600dfe8..f5b9a3b807 100644 --- a/website/client/src/components/header/notificationsDropdown.vue +++ b/website/client/src/components/header/notificationsDropdown.vue @@ -149,6 +149,7 @@ import ACHIEVEMENT_MIND_OVER_MATTER from './notifications/mindOverMatter'; import ONBOARDING_COMPLETE from './notifications/onboardingComplete'; import GIFT_ONE_GET_ONE from './notifications/g1g1'; import OnboardingGuide from './onboardingGuide'; +import DROP_CAP_REACHED from './notifications/dropCapReached'; export default { components: { @@ -178,6 +179,7 @@ export default { OnboardingGuide, ONBOARDING_COMPLETE, GIFT_ONE_GET_ONE, + DROP_CAP_REACHED, }, data () { return { @@ -203,7 +205,7 @@ export default { 'GROUP_TASK_CLAIMED', 'NEW_MYSTERY_ITEMS', 'CARD_RECEIVED', 'NEW_INBOX_MESSAGE', 'NEW_CHAT_MESSAGE', 'UNALLOCATED_STATS_POINTS', 'ACHIEVEMENT_JUST_ADD_WATER', 'ACHIEVEMENT_LOST_MASTERCLASSER', 'ACHIEVEMENT_MIND_OVER_MATTER', - 'VERIFY_USERNAME', 'ONBOARDING_COMPLETE', + 'VERIFY_USERNAME', 'ONBOARDING_COMPLETE', 'DROP_CAP_REACHED', ], }; }, diff --git a/website/client/src/components/header/userDropdown.vue b/website/client/src/components/header/userDropdown.vue index ecfbf2a4ad..ec0bc2547a 100644 --- a/website/client/src/components/header/userDropdown.vue +++ b/website/client/src/components/header/userDropdown.vue @@ -23,13 +23,6 @@ slot="dropdown-content" class="user-dropdown" > - -

{{ user.profile.name }}

- {{ $t('editAvatar') }} -
{{ $t('backgrounds') }} + @click="showAvatar('body', 'size')" + >{{ $t('editAvatar') }} + {{ $t('profile') }} {{ $t('stats') }} {{ $t('achievements') }} - {{ $t('profile') }} {{ $t('logout') }} @@ -96,39 +106,30 @@ diff --git a/website/client/src/components/members/classBadge.vue b/website/client/src/components/members/classBadge.vue index 2666952e6a..b6ce43d7d7 100644 --- a/website/client/src/components/members/classBadge.vue +++ b/website/client/src/components/members/classBadge.vue @@ -2,8 +2,8 @@
diff --git a/website/client/src/components/notifications.vue b/website/client/src/components/notifications.vue index 206ebabd3d..d6c8e4c563 100644 --- a/website/client/src/components/notifications.vue +++ b/website/client/src/components/notifications.vue @@ -35,6 +35,7 @@ + @@ -145,6 +146,7 @@ import loginIncentives from './achievements/login-incentives'; import onboardingComplete from './achievements/onboardingComplete'; import verifyUsername from './settings/verifyUsername'; import firstDrops from './achievements/firstDrops'; +import DropCapReachedModal from '@/components/achievements/dropCapReached'; const NOTIFICATIONS = { CHALLENGE_JOINED_ACHIEVEMENT: { @@ -384,6 +386,7 @@ export default { justAddWater, onboardingComplete, firstDrops, + DropCapReachedModal, }, mixins: [notifications, guide], data () { diff --git a/website/client/src/components/shops/buyModal.vue b/website/client/src/components/shops/buyModal.vue index df11994c8f..170b5436b8 100644 --- a/website/client/src/components/shops/buyModal.vue +++ b/website/client/src/components/shops/buyModal.vue @@ -7,9 +7,9 @@ @@ -154,8 +154,8 @@ !enoughCurrency(getPriceClass(), item.value * selectedAmountToBuy)" :class="{'notEnough': !preventHealthPotion || !enoughCurrency(getPriceClass(), item.value * selectedAmountToBuy)}" - @click="buyItem()" tabindex="0" + @click="buyItem()" > {{ $t('buyNow') }} diff --git a/website/client/src/components/shops/shopItem.vue b/website/client/src/components/shops/shopItem.vue index 4c0608a63a..f3bc30e77c 100644 --- a/website/client/src/components/shops/shopItem.vue +++ b/website/client/src/components/shops/shopItem.vue @@ -3,9 +3,9 @@
{{ $t(filter) }}
diff --git a/website/client/src/components/tasks/task.vue b/website/client/src/components/tasks/task.vue index a434bc6a8f..4ddc668f2d 100644 --- a/website/client/src/components/tasks/task.vue +++ b/website/client/src/components/tasks/task.vue @@ -36,11 +36,11 @@ 'task-not-scoreable': isUser !== true || (task.group.approval.requested && !task.group.approval.approved), }, controlClass.up.inner]" + tabindex="0" @click="(isUser && task.up && (!task.group.approval.requested || task.group.approval.approved)) ? score('up') : null" @keypress.enter="(isUser && task.up && (!task.group.approval.requested || task.group.approval.approved)) ? score('up') : null" - tabindex="0" >