old client structure

This commit is contained in:
Matteo Pagliazzi
2019-10-01 11:44:38 +02:00
parent 5b61f65711
commit b44fbdcb14
906 changed files with 44 additions and 44 deletions
+55
View File
@@ -0,0 +1,55 @@
<template lang="pug">
.row
.col-12.text-center
// @TODO i18n. How to setup the strings with the router-link inside?
img.not-found-img(src='~assets/images/404.png')
h1.not-found Sometimes even the bravest adventurer gets lost.
h2.not-found Looks like this link is broken or the page may have moved, sorry!
h2.not-found Head back to the <router-link to="/">Homepage</router-link> or <router-link :to="contactUsLink">Contact Us</router-link> about the issue.
</template>
<script>
import { mapState } from 'client/libs/store';
export default {
computed: {
...mapState(['isUserLoggedIn']),
contactUsLink () {
if (this.isUserLoggedIn) {
return {name: 'guild', params: {groupId: 'a29da26b-37de-4a71-b0c6-48e72a900dac'} };
} else {
return {name: 'contact'};
}
},
},
};
</script>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.col-12 {
margin-bottom: 120px;
}
.not-found-img {
margin-top: 152px;
margin-bottom: 42px;
}
h1.not-found {
line-height: 1.33;
color: $purple-200;
margin-bottom: 8px;
font-weight: normal;
margin-top: 0px;
}
h2.not-found {
line-height: 1.4;
font-weight: normal;
color: $gray-200;
margin-bottom: 0px;
margin-top: 0px;
}
</style>
@@ -0,0 +1,26 @@
<template lang="pug">
div(:class='achievementClass')
//- +generatedAvatar({sleep: false})
avatar(:member='user', :avatarOnly='true', :withBackground='true')
</template>
<script>
import { mapState } from 'client/libs/store';
import Avatar from '../avatar';
export default {
components: {
Avatar,
},
data () {
return {
achievementClass: `achievement-${this.badge}2x`,
};
},
computed: {
...mapState({
user: 'user.data',
}),
},
};
</script>
@@ -0,0 +1,79 @@
<template lang="pug">
.modal-footer(style='margin-top:0', ng-init='loadWidgets()')
.container-fluid.share-buttons
.row
.col-12.text-center
a.twitter-share-button.share-button(:href='twitterLink', target='_blank')
.social-icon.twitter.svg-icon(v-html='icons.twitter')
| {{ $t('tweet') }}
a.fb-share-button.share-button(:href='facebookLink', target='_blank')
.social-icon.facebook.svg-icon(v-html='icons.facebook')
| {{ $t('share') }}
// @TODO: Still want this? .col-4
a.tumblr-share-button(:data-href='socialLevelLink', data-notes='none')
</template>
<style scoped>
.share-buttons {
margin-top: 1em;
margin-bottom: 1em;
}
.share-button {
display: inline-block;
width: 77px;
padding: .5em;
border-radius: 2px;
text-align: center;
color: #fff;
}
.fb-share-button {
background-color: #2995cd;
}
.twitter-share-button {
margin-right: .5em;
background-color: #3bcad7;
}
.social-icon {
width: 16px;
display: inline-block;
vertical-align: bottom;
margin-right: .5em;
}
.social-icon.facebook svg {
width: 7.5px;
margin-bottom: .2em;
}
.social-icon.twitter {
margin-bottom: .2em;
}
</style>
<script>
// @TODO:
let BASE_URL = 'https://habitica.com';
import twitter from 'assets/svg/twitter.svg';
import facebook from 'assets/svg/facebook.svg';
export default {
data () {
let tweet = this.$t('achievementShare');
return {
icons: Object.freeze({
twitter,
facebook,
}),
tweet,
achievementLink: `${BASE_URL}`,
twitterLink: `https://twitter.com/intent/tweet?text=${tweet}&via=habitica&url=${BASE_URL}&count=none`,
facebookLink: `https://www.facebook.com/sharer/sharer.php?text=${tweet}&u=${BASE_URL}`,
};
},
};
</script>
@@ -0,0 +1,28 @@
<template lang="pug">
b-modal#armoire-empty(:title="$t('armoireText')", size='lg', :hide-footer="true")
.modal-body
.row
.col-6.offset-3
.shop_armoire
p {{$t('armoireLastItem')}}
p {{$t('armoireNotesEmpty')}}
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{$t('close')}}
</template>
<style scoped>
.shop_armoire {
margin: 1em auto;
}
</style>
<script>
export default {
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'armoire-empty');
},
},
};
</script>
@@ -0,0 +1,203 @@
<template lang="pug">
b-modal#choose-class(
size='lg',
:hide-header='true',
:hide-footer='true',
:no-close-on-esc='true',
:no-close-on-backdrop='true',
)
.modal-body.select-class
h1.header-purple.text-center {{ $t('chooseClass') }}
.container-fluid
br
.row
.col-md-3(v-for='heroClass in classes')
div(@click='selectedClass = heroClass')
avatar(
:member='user',
:avatarOnly='true',
:withBackground='false',
:overrideAvatarGear='classGear(heroClass)',
:hideClassBadge='true',
:spritesMargin='"1.8em 1.5em"',
:overrideTopPadding='"0px"',
:showVisualBuffs='false',
:class='selectionBox(selectedClass, heroClass)',
)
br
.d-flex.justify-content-center(v-for='heroClass in classes')
.d-inline-flex(v-if='selectedClass === heroClass')
.class-badge.d-flex.justify-content-center
.svg-icon.align-self-center(v-html='icons[heroClass]')
.class-name(:class='`${heroClass}-color`') {{ $t(heroClass) }}
div(v-for='heroClass in classes')
.class-explanation.text-center(v-if='selectedClass === heroClass') {{ $t(`${heroClass}Text`) }}
.text-center(v-markdown='$t("chooseClassLearnMarkdown")')
.modal-actions.text-center
button.btn.btn-primary.d-inline-block(v-if='!selectedClass', :disabled='true') {{ $t('select') }}
button.btn.btn-primary.d-inline-block(v-else, @click='clickSelectClass(selectedClass); close();') {{ $t('selectClass', {heroClass: $t(selectedClass)}) }}
.opt-out-wrapper
span#classOptOutBtn.danger(@click='clickDisableClasses(); close();') {{ $t('optOutOfClasses') }}
span.opt-out-description {{ $t('optOutOfClassesText') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.class-badge {
$badge-size: 32px;
width: $badge-size;
height: $badge-size;
background: $white;
box-shadow: 0 2px 2px 0 rgba($black, 0.16), 0 1px 4px 0 rgba($black, 0.12);
border-radius: 100px;
.svg-icon {
width: 19px;
height: 19px;
}
}
.class-explanation {
font-size: 16px;
margin: 1.5em auto;
}
#classOptOutBtn {
cursor: pointer;
}
.class-name {
font-size: 24px;
font-weight: bold;
margin: auto 0.33333em;
}
.danger {
color: $red-50;
margin-bottom: 0em;
}
.header-purple {
color: $purple-200;
margin-top: 1.33333em;
margin-bottom: 0em;
}
.modal-actions {
margin: 2em auto;
}
.opt-out-wrapper {
margin: 1em 0 0.5em 0;
}
.selection-box {
width: 140px;
height: 148px;
border-radius: 16px;
border: solid 4px $purple-300;
}
.healer-color {
color: $yellow-10;
}
.rogue-color {
color: $purple-200;
}
.warrior-color {
color: $red-50;
}
.wizard-color {
color: $blue-10;
}
</style>
<script>
import Avatar from '../avatar';
import { mapState } from 'client/libs/store';
import markdownDirective from 'client/directives/markdown';
import warriorIcon from 'assets/svg/warrior.svg';
import rogueIcon from 'assets/svg/rogue.svg';
import healerIcon from 'assets/svg/healer.svg';
import wizardIcon from 'assets/svg/wizard.svg';
export default {
components: {
Avatar,
},
computed: {
...mapState({
user: 'user.data',
classes: 'content.classes',
}),
},
data () {
return {
icons: Object.freeze({
warrior: warriorIcon,
rogue: rogueIcon,
healer: healerIcon,
wizard: wizardIcon,
}),
selectedClass: 'warrior',
};
},
directives: {
markdown: markdownDirective,
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'choose-class');
},
clickSelectClass (heroClass) {
if (this.user.flags.classSelected && !confirm(this.$t('changeClassConfirmCost'))) return;
this.$store.dispatch('user:changeClass', {query: {class: heroClass}});
},
clickDisableClasses () {
this.$store.dispatch('user:disableClasses');
},
classGear (heroClass) {
if (heroClass === 'rogue') {
return {
armor: 'armor_special_fall2019Rogue',
eyewear: 'eyewear_special_fall2019Rogue',
head: 'head_special_fall2019Rogue',
shield: 'shield_special_fall2019Rogue',
weapon: 'weapon_special_fall2019Rogue',
};
} else if (heroClass === 'wizard') {
return {
armor: 'armor_special_fall2019Mage',
head: 'head_special_fall2019Mage',
weapon: 'weapon_special_fall2019Mage',
};
} else if (heroClass === 'healer') {
return {
armor: 'armor_special_fall2019Healer',
eyewear: 'eyewear_special_fall2019Healer',
head: 'head_special_fall2019Healer',
shield: 'shield_special_fall2019Healer',
weapon: 'weapon_special_fall2019Healer',
};
} else {
return {
armor: 'armor_special_fall2019Warrior',
head: 'head_special_fall2019Warrior',
shield: 'shield_special_fall2019Warrior',
weapon: 'weapon_special_fall2019Warrior',
};
}
},
selectionBox (selectedClass, heroClass) {
if (selectedClass === heroClass) {
return 'selection-box';
}
},
},
};
</script>
@@ -0,0 +1,45 @@
<template lang="pug">
b-modal#contributor(:title="$t('modalContribAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
achievement-avatar.avatar
.col-6.offset-3.text-center
| {{ $t('contribModal', {name: user.profile.name, level: user.contributor.level}) }}
br
a(:href="$t('conRewardsURL')", target='_blank') {{ $t('contribLink') }}
br
button.btn.btn-primary(style='margin-top:1em' @click='close()') {{ $t('huzzah') }}
br
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import { mapState } from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'contributor');
},
},
};
</script>
@@ -0,0 +1,75 @@
<template lang="pug">
b-modal#death(
:title="$t('lostAllHealth')",
size='md',
:hide-footer="true",
no-close-on-esc,
no-close-on-backdrop,
)
.row
.col-12
.hero-stats
.meter-label(:tooltip="$t('health')")
span.glyphicon.glyphicon-heart
.meter.health(:tooltip='Math.round(user.stats.hp * 100) / 100')
.bar(:style='barStyle')
// span.meter-text.value
| {{Math.ceil(user.stats.hp)}} / {{maxHealth}}
avatar(:member='user', :sleep='true', :avatarOnly='true', :withBackground='true')
// @TOOD: Sleep +generatedAvatar({sleep:true})
span(class='knockout')
.col-6.offset-3
h4.dont-despair {{ $t('dontDespair') }}
p.death-penalty {{ $t('deathPenaltyDetails') }}
.modal-footer
.col-12.text-center
button.btn.btn-danger(@click='revive()') {{ $t('refillHealthTryAgain') }}
h4.text-center(v-html="$t('dyingOftenTips')")
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import axios from 'axios';
import Avatar from '../avatar';
import { mapState } from 'client/libs/store';
import percent from '../../../common/script/libs/percent';
import {maxHealth} from '../../../common/script/index';
import revive from '../../../common/script/ops/revive';
export default {
components: {
Avatar,
},
data () {
return {
maxHealth,
};
},
computed: {
...mapState({user: 'user.data'}),
barStyle () {
return {
width: `${percent(this.user.stats.hp, maxHealth)}%`,
};
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'death');
},
async revive () {
await axios.post('/api/v4/user/revive');
revive(this.user);
this.close();
},
},
};
</script>
@@ -0,0 +1,47 @@
<template lang="pug">
b-modal#drops-enabled(:title="$t('dropsEnabled')", size='lg', :hide-footer="true")
.modal-body
.col-6.offset-3.text-center
p
.item-drop-icon(class='Pet_Egg_Wolf')
span(v-html='firstDropText')
p
.item-drop-icon(class='Pet_Currency_Gem')
span(v-html="$t('useGems')")
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{ $t('close') }}
</template>
<style scoped>
.item-drop-icon {
margin: 0 auto;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import eggs from '../../../common/script/content/eggs';
export default {
data () {
return {
eggs,
};
},
computed: {
...mapState({user: 'user.data'}),
firstDropText () {
return this.$t('firstDrop', {
eggText: this.eggs.all.Wolf.text(),
eggNotes: this.eggs.all.Wolf.notes(),
});
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'drops-enabled');
},
},
};
</script>
@@ -0,0 +1,42 @@
<template lang="pug">
b-modal#generic-achievement(:title='data.message', size='md', :hide-footer='true')
.modal-body
.col-12
achievement-avatar.avatar
.col-6.offset-3.text-center
p(v-html='data.modalText')
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import {mapState} from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
props: ['data'],
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'generic-achievement');
},
},
};
</script>
@@ -0,0 +1,38 @@
<template lang="pug">
b-modal#invited-friend(:title="$t('modalAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('friends',0)
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('invitedFriendText') }}
br
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
export default {
components: {
achievementFooter,
achievementAvatar,
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'invited-friend');
},
},
};
</script>
@@ -0,0 +1,38 @@
<template lang="pug">
b-modal#joined-challenge(:title="$t('modalAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('challenge',0)
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('joinedChallengeText') }}
br
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
export default {
components: {
achievementFooter,
achievementAvatar,
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'joined-challenge');
},
},
};
</script>
@@ -0,0 +1,38 @@
<template lang="pug">
b-modal#joined-guild(:title="$t('modalAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('guild',0)
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('joinedGuildText') }}
br
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
export default {
components: {
achievementFooter,
achievementAvatar,
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'joined-guild');
},
},
};
</script>
@@ -0,0 +1,46 @@
<template lang="pug">
b-modal#just-add-water(:title='title', size='md', :hide-footer='true')
.modal-body
.col-12
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('achievementJustAddWaterModalText') }}
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import {mapState} from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
data () {
return {
title: `${this.$t('modalAchievement')} ${this.$t('achievementJustAddWater')}`,
};
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'just-add-water');
},
},
};
</script>
@@ -0,0 +1,167 @@
<template lang="pug">
b-modal#level-up(:title="$t('levelUpShare')", size='sm', :hide-footer="true", :hide-header="true")
.modal-body.text-center
h2 {{ $t('reachedLevel', {level: user.stats.lvl}) }}
avatar.avatar(:member='user')
p.text {{ $t('levelup') }}
button.btn.btn-primary(@click='close()') {{ $t('onwards') }}
br
// @TODO: Keep this? .checkbox
input(type='checkbox', v-model='user.preferences.suppressModals.levelUp', @change='changeLevelupSuppress()')
label(style='display:inline-block') {{ $t('dontShowAgain') }}
.container-fluid.share-buttons
.row
.col-12.text-center
a.twitter-share-button.share-button(:href='twitterLink', target='_blank')
.social-icon.twitter.svg-icon(v-html='icons.twitter')
| {{ $t('tweet') }}
a.fb-share-button.share-button(:href='facebookLink', target='_blank')
.social-icon.facebook.svg-icon(v-html='icons.facebook')
| {{ $t('share') }}
// @TODO: Still want this? .col-4
a.tumblr-share-button(:data-href='socialLevelLink', data-notes='none')
</template>
<style lang="scss">
#level-up {
h2 {
color: #4f2a93;
}
.modal-content {
min-width: 28em;
}
.modal-body {
padding-top: 1em;
padding-bottom: 0;
}
.modal-footer {
margin-top: 0;
}
.herobox {
margin: auto 8.3em;
width: 6em;
height: 9em;
padding-top: 0;
cursor: default;
}
.character-sprites {
margin: 0;
width: 0;
}
.text {
font-size: 14px;
text-align: center;
color: #686274;
margin-top: 1em;
min-height: 0px;
}
.share-buttons {
margin-top: 1em;
margin-bottom: 1em;
}
.share-button {
display: inline-block;
width: 77px;
padding: .5em;
border-radius: 2px;
text-align: center;
color: #fff;
}
.fb-share-button {
background-color: #2995cd;
}
.twitter-share-button {
margin-right: .5em;
background-color: #3bcad7;
}
.social-icon {
width: 16px;
display: inline-block;
vertical-align: bottom;
margin-right: .5em;
}
.social-icon.facebook svg {
width: 7.5px;
margin-bottom: .2em;
}
.social-icon.twitter {
margin-bottom: .2em;
}
}
</style>
<style scoped>
.avatar {
margin-left: 6.8em;
}
</style>
<script>
import Avatar from '../avatar';
import { mapState } from 'client/libs/store';
import {maxHealth} from '../../../common/script/index';
import styleHelper from 'client/mixins/styleHelper';
import twitter from 'assets/svg/twitter.svg';
import facebook from 'assets/svg/facebook.svg';
let BASE_URL = 'https://habitica.com';
export default {
mixins: [styleHelper],
components: {
Avatar,
},
data () {
let tweet = this.$t('levelUpShare');
return {
icons: Object.freeze({
twitter,
facebook,
}),
statsAllocationBoxIsOpen: true,
maxHealth,
tweet,
socialLevelLink: `${BASE_URL}/social/level-up`,
twitterLink: `https://twitter.com/intent/tweet?text=${tweet}&via=habitica&url=${BASE_URL}/social/level-up&count=none`,
facebookLink: `https://www.facebook.com/sharer/sharer.php?text=${tweet}&u=${BASE_URL}/social/level-up`,
};
},
mounted () {
this.loadWidgets();
},
computed: {
...mapState({user: 'user.data'}),
showAllocation () {
return this.$store.getters['members:hasClass'](this.user) && !this.user.preferences.automaticAllocation;
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'level-up');
},
loadWidgets () {
// @TODO:
},
changeLevelupSuppress () {
// @TODO: dispatch set({"preferences.suppressModals.levelUp": user.preferences.suppressModals.levelUp?true: false})
},
},
};
</script>
@@ -0,0 +1,85 @@
<template lang="pug">
b-modal#login-incentives(:title="data.message", size='md', :hide-footer="true")
.modal-body
.row
h3.col-12.text-center(v-if='data.rewardText') {{ $t('unlockedReward', {reward: data.rewardText}) }}
.row.reward-row
.col-12
avatar.avatar(:member='user', :avatarOnly='true', :withBackground='true')
.text-center.col-12(v-if='nextReward')
.reward-wrap(v-if="!data.rewardText")
div(v-if="nextReward.rewardKey.length === 1", :class="nextReward.rewardKey[0]")
.reward(v-for="reward in nextReward.rewardKey", v-if="nextReward.rewardKey.length > 1", :class='reward')
.reward-wrap(v-if="data.rewardText")
div(v-if="data.rewardKey.length === 1", :class="data.rewardKey[0]")
.reward(v-for="reward in data.rewardKey", v-if="data.rewardKey.length > 1", :class='reward')
.col-12.text-center(v-if="data && data.nextRewardAt")
h4 {{ $t('countLeft', {count: data.nextRewardAt - user.loginIncentives}) }}
.row
.col-12.text-center(v-if='data.rewardText')
p {{ $t('earnedRewardForDevotion', {reward: data.rewardText}) }}
.col-12.text-center
p {{ $t('incentivesDescription') }}
.col-12.text-center(v-if="data && data.nextRewardAt")
h3 {{ $t('nextRewardUnlocksIn', {numberOfCheckinsLeft: data.nextRewardAt - user.loginIncentives}) }}
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{ $t('awesome') }}
</template>
<style scoped>
.avatar {
width: 140px;
}
.reward-wrap {
width: max-content;
}
.avatar, .reward-wrap {
margin: 0 auto;
}
.reward-row {
margin-top: 2em;
margin-bottom: 2em;
}
.reward {
float: left;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import Avatar from '../avatar';
import {loginIncentives} from '../../../common/script/content/index';
export default {
components: {
Avatar,
},
props: ['data'],
data () {
return {
loginIncentives,
};
},
computed: {
...mapState({
user: 'user.data',
}),
nextReward () {
if (!this.loginIncentives[this.user.loginIncentives]) return;
const nextRewardKey = this.loginIncentives[this.user.loginIncentives].nextRewardAt;
const nextReward = this.loginIncentives[nextRewardKey];
return nextReward;
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'login-incentives');
},
},
};
</script>
@@ -0,0 +1,46 @@
<template lang="pug">
b-modal#lost-masterclasser(:title='title', size='md', :hide-footer='true')
.modal-body
.col-12
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('achievementLostMasterclasserModalText') }}
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import {mapState} from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
data () {
return {
title: `${this.$t('modalAchievement')} ${this.$t('achievementLostMasterclasser')}`,
};
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'lost-masterclasser');
},
},
};
</script>
@@ -0,0 +1,96 @@
<template lang="pug">
b-modal#low-health(:title="$t('losingHealthWarning')", size='md', :hide-footer="true")
.modal-body
.col-12.text-center
.meter-label(:tooltip="$t('health')")
span.glyphicon.glyphicon-heart
.meter.health(:tooltip='Math.round(user.stats.hp * 100) / 100')
.bar(:style='barStyle')
span.meter-text.value
| {{healthLeft}}
.col-12
avatar(:member='user', :avatarOnly='true', :withBackground='true')
.col-12
p {{ $t('losingHealthWarning2') }}
h4 {{ $t('toRegainHealth') }}
ul
li.spaced {{ $t('lowHealthTips1') }}
li.spaced {{ $t('lowHealthTips2') }}
h4 {{ $t('losingHealthQuickly') }}
ul
li.spaced {{ $t('lowHealthTips3') }}
li.spaced {{ $t('lowHealthTips4') }}
h4 {{ $t('goodLuck') }}
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='acknowledgeHealthWarning()') {{ $t('ok') }}
</template>
<style scoped>
.hero-stats {
position: absolute;
margin-left: 9em;
width: 50%;
}
.character-sprites {
display: inline-flex;
margin: 3em auto;
}
.herobox {
padding-top: 0em;
margin-left: 16em;
}
.modal-footer {
margin-top: 0em;
}
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import Avatar from '../avatar';
import { mapState } from 'client/libs/store';
import percent from '../../../common/script/libs/percent';
import {maxHealth} from '../../../common/script/index';
export default {
components: {
Avatar,
},
data () {
return {
maxHealth,
};
},
computed: {
...mapState({user: 'user.data'}),
barStyle () {
return {
width: `${percent(this.user.stats.hp, maxHealth)}%`,
};
},
healthLeft () {
return `${Math.ceil(this.user.stats.hp)} / ${this.maxHealth}`;
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'low-health');
},
acknowledgeHealthWarning () {
this.$store.dispatch('user:set', {
'flags.warnedLowHealth': true,
});
this.close();
},
},
};
</script>
@@ -0,0 +1,46 @@
<template lang="pug">
b-modal#mind-over-matter(:title='title', size='md', :hide-footer='true')
.modal-body
.col-12
achievement-avatar.avatar
.col-6.offset-3.text-center
p {{ $t('achievementMindOverMatterModalText') }}
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import {mapState} from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
data () {
return {
title: `${this.$t('modalAchievement')} ${this.$t('achievementMindOverMatter')}`,
};
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'mind-over-matter');
},
},
};
</script>
@@ -0,0 +1,60 @@
<template lang="pug">
b-modal#new-stuff(
size='lg',
:hide-header='true',
:hide-footer='true',
no-close-on-esc,
no-close-on-backdrop
)
.modal-body
.static-view(v-html='html')
.modal-footer
a.btn.btn-info(href='http://habitica.fandom.com/wiki/Whats_New', target='_blank') {{ this.$t('newsArchive') }}
button.btn.btn-secondary(@click='tellMeLater()') {{ this.$t('tellMeLater') }}
button.btn.btn-warning(@click='dismissAlert();') {{ this.$t('dismissAlert') }}
</template>
<style lang='scss'>
@import '~client/assets/scss/static.scss';
#new-stuff {
.modal-body .modal-body {
padding-top: 0rem;
}
}
</style>
<script>
import axios from 'axios';
import { mapState } from 'client/libs/store';
export default {
data () {
return {
html: '',
};
},
computed: {
...mapState({user: 'user.data'}),
},
async mounted () {
this.$root.$on('bv::show::modal', async (modalId) => {
if (modalId !== 'new-stuff') return;
let response = await axios.get('/api/v4/news');
this.html = response.data.html;
});
},
destroyed () {
this.$root.$off('bv::show::modal');
},
methods: {
tellMeLater () {
this.$store.dispatch('user:newStuffLater');
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
dismissAlert () {
this.$store.dispatch('user:set', {'flags.newStuff': false});
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
},
};
</script>
@@ -0,0 +1,69 @@
<template lang="pug">
b-modal#quest-completed(v-if='user.party.quest.completed', :title="title",
size='md', :hide-footer="true", :no-close-on-esc="true", :no-close-on-backdrop="true",
@hide='hide')
.modal-body.text-center
.quest(:class='`quest_${user.party.quest.completed}`')
p(v-if='questData.completion && typeof questData.completion === "function"', v-html='questData.completion()')
.quest-rewards.text-center
h3(v-once) {{ $t('paymentYouReceived') }}
questDialogDrops(:item="questData")
.modal-footer
button.btn.btn-primary(@click='setQuestCompleted()') {{ $t('ok') }}
</template>
<style scoped>
.quest {
margin: 0 auto;
}
.quest-rewards .questRewards {
margin: 0 auto;
}
</style>
<script>
import quests from 'common/script/content/quests';
import questDialogDrops from 'client/components/shops/quests/questDialogDrops';
import { mapState } from 'client/libs/store';
import percent from '../../../common/script/libs/percent';
import { maxHealth } from '../../../common/script/index';
export default {
components: {
questDialogDrops,
},
data () {
return {
maxHealth,
quests,
};
},
computed: {
...mapState({user: 'user.data'}),
questData () {
return this.quests.quests[this.user.party.quest.completed];
},
title () {
return `${this.questData.text()} ${this.$t('completed')}`;
},
barStyle () {
return {
width: `${percent(this.user.stats.hp, maxHealth)}%`,
};
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'quest-completed');
},
setQuestCompleted () {
this.close();
},
hide () {
this.$store.dispatch('user:set', {'party.quest.completed': ''});
},
},
};
</script>
@@ -0,0 +1,74 @@
<template lang="pug">
b-modal#quest-invitation(v-if='user.party.quest.key && quests[user.party.quest.key]', :title="$t('questInvitation')", size='lg', :hide-footer="true")
.modal-header
h4 {{ $t('questInvitation') }}
|&nbsp;{{quests[user.party.quest.key].text()}}
.modal-body
.pull-right-sm.text-center
.col-centered(:class='`quest_${quests[user.party.quest.key].key}`')
div(ng-if='quests[user.party.quest.key].boss')
h4 {{quests[user.party.quest.key].boss.name()}}
p
strong {{ $t('bossHP') }} + ': '
| {{quests[user.party.quest.key].boss.hp}}
p
strong {{ $t('bossStrength') }} + ': '
| {{quests[user.party.quest.key].boss.str}}
div(ng-if='quests[user.party.quest.key].collect')
p(ng-repeat='(k,v) in quests[user.party.quest.key].collect')
strong {{ $t('collect') }} + ': '
| {{quests[user.party.quest.key].collect[k].count}} {{quests[user.party.quest.key].collect[k].text()}}
div(ng-bind-html='quests[user.party.quest.key].notes()')
.quest-rewards(:key='user.party.quest.key', header-participant="$t('rewardsAllParticipants')", header-quest-owner="$t('rewardsQuestOwner')")
hr
h5 {{headerParticipant}}
table.table.table-striped
tr(ng-repeat='drop in _.reject(quest.drop.items, \'onlyOwner\')')
td {{drop.text()}}
tr(ng-if='quest.drop.exp > 0')
td {{quest.drop.exp}}&nbsp;
| {{ $t('experience') }}
tr(ng-if='quest.drop.gp > 0')
td {{quest.drop.gp}}&nbsp;
| {{ $t('gold') }}
tr(ng-if='quest.drop.unlock()')
td {{quest.drop.unlock()}}
div(ng-if='getQuestOwnerRewards(quest).length > 0')
h5 {{headerQuestOwner}}
table.table.table-striped
tr(ng-repeat='drop in getQuestOwnerRewards(quest)')
td {{drop.text()}}
.modal-footer
button.btn.btn-secondary(ng-click='questHold = true; $close()') {{ $t('askLater') }}
button.btn.btn-secondary(ng-click='questReject(); $close()') {{ $t('reject') }}
button.btn.btn-primary(ng-click='questAccept(); $close()') {{ $t('accept') }}
</template>
<script>
import quests from 'common/script/content/quests';
import { mapState } from 'client/libs/store';
import percent from '../../../common/script/libs/percent';
import {maxHealth} from '../../../common/script/index';
export default {
data () {
return {
maxHealth,
quests,
};
},
computed: {
...mapState({user: 'user.data'}),
barStyle () {
return {
width: `${percent(this.user.stats.hp, maxHealth)}%`,
};
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'quest-invitation');
},
},
};
</script>
@@ -0,0 +1,46 @@
<template lang="pug">
b-modal#rebirth(:title="$t('modalAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('sun',0)
achievement-avatar.avatar
.col-6.offset-3.text-center
div(v-if='user.achievements.rebirthLevel < 100')
| {{ $t('rebirthAchievement', {number: user.achievements.rebirths, level: user.achievements.rebirthLevel}) }}
div(v-if='user.achievements.rebirthLevel >= 100')
| {{ $t('rebirthAchievement100', {number: user.achievements.rebirths}) }}
br
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import {mapState} from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'rebirth');
},
},
};
</script>
@@ -0,0 +1,32 @@
<template lang="pug">
b-modal#rebirth-enabled(:title="$t('rebirthNew')", size='md', :hide-footer="true")
.modal-body
.col-12
.rebirth_orb
p
span {{ $t('rebirthUnlock') }}
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{ $t('close') }}
</template>
<style scoped>
.rebirth_orb {
margin: 0 auto;
}
</style>
<script>
import { mapState } from 'client/libs/store';
export default {
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'rebirth-enabled');
},
},
};
</script>
@@ -0,0 +1,53 @@
<template lang="pug">
b-modal#streak(:title="$t('streakAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('thermometer',2.5)
achievement-avatar.avatar
.col-6.offset-3.text-center
h3(v-if='user.achievements.streak === 1') {{ $t('firstStreakAchievement') }}
h3(v-if='user.achievements.streak > 1') {{ $t('streakAchievementCount', {streaks: user.achievements.streak}) }}
p {{ $t('twentyOneDays') }}
p {{ $t('dontBreakStreak') }}
br
button.btn.btn-primary(@click='close()') {{ $t('dontStop') }}
.checkbox
input(type='checkbox', v-model='user.preferences.suppressModals.streak', @change='suppressModals')
label {{ $t('dontShowAgain') }}
achievement-footer
</template>
<style scoped>
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import { mapState } from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'streak');
},
suppressModals () {
let surpress = this.user.preferences.suppressModals.streak ? true : false;
this.$store.dispatch('user:set', {'preferences.suppressModals.streak': surpress});
},
},
};
</script>
@@ -0,0 +1,35 @@
<template lang="pug">
b-modal#testing(:title="$t('guildReminderTitle')", size='lg', :hide-footer="true")
.modal-body.text-center
br
.scene_guilds
br
h4 {{ $t('guildReminderText1') }}
.modal-footer
.container-fluid
.row
.col-6.text-center
button.btn.btn-secondary(@click='close()') {{ $t('guildReminderDismiss') }}
.col-6.text-center(@click='close()')
.btn.btn-primary(@click='takeMethere()') {{ $t('guildReminderCTA') }}
</template>
<style scoped>
.scene_guilds {
margin: 0 auto;
}
</style>
<script>
export default {
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'testing');
},
takeMethere () {
this.$router.push('/groups/discovery');
this.close();
},
},
};
</script>
@@ -0,0 +1,36 @@
<template lang="pug">
b-modal#testingletiant(:title="$t('guildReminderTitle')", size='lg', :hide-footer="true")
.modal-content
.modal-body.text-center
br
.scene_guilds
br
h4 {{ $t('guildReminderText2') }}
.modal-footer
.container-fluid
.row
.col-6.text-center
button.btn.btn-secondary(@click='close()') {{ $t('guildReminderDismiss') }}
.col-6.text-center
.btn.btn-primary(@click='takeMethere()') {{ $t('guildReminderCTA') }}
</template>
<style scoped>
.scene_guilds {
margin: 0 auto;
}
</style>
<script>
export default {
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'testingletiant');
},
takeMethere () {
this.$router.push('/groups/discovery');
this.close();
},
},
};
</script>
@@ -0,0 +1,67 @@
<template lang="pug">
b-modal#ultimate-gear(:title="$t('modalAchievement')", size='md', :hide-footer="true")
.modal-body
.col-12
// @TODO: +achievementAvatar('armor',2.5)
achievement-avatar.avatar
.col-12.text-center
p {{ $t('gearAchievement') }}
br
table.multi-achievement
tr
td(v-if='user.achievements.ultimateGearSets.healer').multi-achievement
.achievement-ultimate-healer2x.multi-achievement
| {{ $t('healer') }}
td(v-if='user.achievements.ultimateGearSets.wizard').multi-achievement
.achievement-ultimate-mage2x.multi-achievement
| {{ $t('mage') }}
td(v-if='user.achievements.ultimateGearSets.rogue').multi-achievement
.achievement-ultimate-rogue2x.multi-achievement
| {{ $t('rogue') }}
td(v-if='user.achievements.ultimateGearSets.warrior').multi-achievement
.achievement-ultimate-warrior2x.multi-achievement
| {{ $t('warrior') }}
br
div(v-if='!(user.achievements.ultimateGearSets.healer && user.achievements.ultimateGearSets.wizard && user.achievements.ultimateGearSets.rogue && user.achievements.ultimateGearSets.warrior)')
p(v-html="$t('moreGearAchievements')")
br
.shop_armoire
p(v-html='$t("armoireUnlocked")')
br
button.btn.btn-primary(@click='close()') {{ $t('huzzah') }}
achievement-footer
</template>
<style scoped>
.shop_armoire {
margin: 0 auto;
}
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import { mapState } from 'client/libs/store';
export default {
components: {
achievementFooter,
achievementAvatar,
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'ultimate-gear');
},
},
};
</script>
@@ -0,0 +1,43 @@
<template lang="pug">
b-modal#welcome(:title="$t('welcomeToHabit')", size='lg', :hide-footer="true")
.modal-body.container-fluid
.row
.col-4.col-centered
span(style='display:flex')
h1
| &#9312;
h3(style='margin:auto auto auto .5em') {{ $t('welcome1') }}
.welcome_basic_avatars(style='margin: 1.5em auto 1.5em')
h4 {{ $t('welcome1notes') }}
.col-4.col-centered
span(style='display:flex')
h1
| &#9313;
h3(style='margin:.3em auto auto .5em') {{ $t('welcome2') }}
.welcome_sample_tasks(style='margin: 2.5em auto 1.5em')
h4 {{ $t('welcome2notes') }}
.col-4.col-centered
span(style='display:flex')
h1
| &#9314;
h3(style='margin:auto auto auto .5em') {{ $t('welcome3') }}
.welcome_promo_party(style='margin: 1em auto 1em')
h4 {{ $t('welcome3notes') }}
.modal-footer.text-center(style='margin-top:0')
.col-3
.col-6
button.btn.btn-primary.btn-lg.flex-column(@click='ready()') {{ $t('imReady') }}
.col-3
</template>
<script>
export default {
methods: {
ready () {
// Guide.goto("intro",0)'
this.$router.push('/avatar');
this.$root.$emit('bv::hide::modal', 'welcome');
},
},
};
</script>
@@ -0,0 +1,66 @@
<template lang="pug">
b-modal#won-challenge(:title="$t('wonChallenge')", size='md', :hide-footer="true")
.modal-body.text-center
h4(v-markdown='user.achievements.challenges[user.achievements.challenges.length - 1]')
.row
.col-4
.achievement-karaoke-2x
.col-4
// @TODO: +generatedAvatar({sleep: false})
avatar.avatar(:member='user', :avatar-only='true')
.col-4
.achievement-karaoke-2x
p {{ $t('congratulations') }}
br
button.btn.btn-primary(@click='close()') {{ $t('hurray') }}
.modal-footer
.col-3
a.twitter-share-button(href='https://twitter.com/intent/tweet?text=#{tweet}&via=habitica&url=#{env.BASE_URL}/social/won-challenge&count=none') {{ $t('tweet') }}
.col-4(style='margin-left:.8em')
.fb-share-button(data-href='#{env.BASE_URL}/social/won-challenge', data-layout='button')
.col-4(style='margin-left:.8em')
a.tumblr-share-button(data-href='#{env.BASE_URL}/social/won-challenge', data-notes='none')
</template>
<style scoped>
.achievement-karaoke-2x {
margin: 0 auto;
margin-top: 6em;
}
.avatar {
width: 140px;
margin: 0 auto;
margin-bottom: 1.5em;
margin-top: 1.5em;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import markdownDirective from 'client/directives/markdown';
import Avatar from '../avatar';
export default {
components: {
Avatar,
},
directives: {
markdown: markdownDirective,
},
computed: {
...mapState({user: 'user.data'}),
},
data () {
let tweet = this.$t('wonChallengeShare');
return {
tweet,
};
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'won-challenge');
},
},
};
</script>
+401
View File
@@ -0,0 +1,401 @@
<template lang="pug">
.row.footer-row
buy-gems-modal(v-if='user')
//modify-inventory(v-if="isUserLoaded")
footer.col-12.expanded
.row
.col-12.col-md-2
h3
a(href='https://itunes.apple.com/us/app/habitica/id994882113?ls=1&mt=8', target='_blank') {{ $t('mobileIOS') }}
h3
a(href='https://play.google.com/store/apps/details?id=com.habitrpg.android.habitica', target='_blank') {{ $t('mobileAndroid') }}
.col-12.col-md-2
h3 {{ $t('footerCompany') }}
ul
li
router-link(to='/static/features') {{ $t('companyAbout') }}
li
a(href='https://habitica.wordpress.com/', target='_blank') {{ $t('companyBlog') }}
li
a(href='http://blog.habitrpg.com/', target='_blank') {{ $t('tumblr') }}
li
router-link(to='/static/faq') {{ $t('FAQ') }}
li
a(href='http://habitica.fandom.com/wiki/Whats_New', target='_blank') {{ $t('oldNews') }}
li
router-link(to='/static/merch') {{ $t('merch') }}
li
router-link(to='/static/press-kit') {{ $t('presskit') }}
li
router-link(to='/static/contact') {{ $t('contactUs') }}
.col-12.col-md-2
h3 {{ $t('footerCommunity') }}
ul
li
a(target="_blanck", href="/static/community-guidelines") {{ $t('communityGuidelines') }}
li
router-link(to='/hall/contributors') {{ $t('hall') }}
li
router-link(to='/groups/guild/a29da26b-37de-4a71-b0c6-48e72a900dac') {{ $t('reportBug') }}
li
a(href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents', target='_blank') {{ $t('requestFeature') }}
li(v-html='$t("communityExtensions")')
li(v-html='$t("communityForum")')
li
a(href='https://www.facebook.com/Habitica', target='_blank') {{ $t('communityFacebook') }}
li
a(href='https://www.instagram.com/habitica', target='_blank') {{ $t('communityInstagram') }}
.col-12.col-md-6
.row
.col-6
h3 {{ $t('footerDevs') }}
ul
li
a(href='/apidoc', target='_blank') {{ $t('APIv3') }}
li
a(:href="getDataDisplayToolUrl", target='_blank') {{ $t('dataDisplayTool') }}
li
a(href='http://habitica.fandom.com/wiki/Guidance_for_Blacksmiths', target='_blank') {{ $t('guidanceForBlacksmiths') }}
.col-6.social
h3 {{ $t('footerSocial') }}
.icons
a.social-circle(href='https://twitter.com/habitica', target='_blank')
.social-icon.svg-icon(v-html='icons.twitter')
a.social-circle(href='https://www.instagram.com/habitica/', target='_blank')
.social-icon.svg-icon.instagram(v-html='icons.instagram')
a.social-circle(href='https://www.facebook.com/Habitica', target='_blank')
.social-icon.facebook.svg-icon(v-html='icons.facebook')
.row
.col-12.col-md-8 {{ $t('donateText3') }}
.col-12.col-md-4
button.btn.btn-contribute.btn-flat(@click="donate()", v-if="user")
.svg-icon.heart(v-html="icons.heart")
.text {{ $t('companyDonate') }}
.btn.btn-contribute.btn-flat(v-else)
a(href='http://habitica.fandom.com/wiki/Contributing_to_Habitica', target='_blank')
.svg-icon.heart(v-html="icons.heart")
.text {{ $t('companyContribute') }}
.row
.col-12
hr
.row
.col-12.col-md-5
| © 2019 Habitica. All rights reserved.
.debug.float-left(v-if="!IS_PRODUCTION && isUserLoaded")
button.btn.btn-primary(@click="debugMenuShown = !debugMenuShown") Toggle Debug Menu
.debug-group(v-if="debugMenuShown")
a.btn.btn-secondary(@click="setHealthLow()") Health = 1
a.btn.btn-secondary(@click="addMissedDay(1)") +1 Missed Day
a.btn.btn-secondary(@click="addMissedDay(2)") +2 Missed Days
a.btn.btn-secondary(@click="addMissedDay(8)") +8 Missed Days
a.btn.btn-secondary(@click="addMissedDay(32)") +32 Missed Days
a.btn.btn-secondary(@click="addTenGems()") +10 Gems
a.btn.btn-secondary(@click="addHourglass()") +1 Mystic Hourglass
a.btn.btn-secondary(@click="addGold()") +500GP
a.btn.btn-secondary(@click="plusTenHealth()") + 10HP
a.btn.btn-secondary(@click="addMana()") +MP
a.btn.btn-secondary(@click="addLevelsAndGold()") +Exp +GP +MP
a.btn.btn-secondary(@click="addExp()") +Exp
a.btn.btn-secondary(@click="addOneLevel()") +1 Level
a.btn.btn-secondary(@click="addQuestProgress()", tooltip="+1000 to boss quests. 300 items to collection quests") Quest Progress Up
a.btn.btn-secondary(@click="makeAdmin()") Make Admin
a.btn.btn-secondary(@click="openModifyInventoryModal()") Modify Inventory
.col-12.col-md-2.text-center
.logo.svg-icon(v-html='icons.gryphon')
.col-12.col-md-5.text-right
span.ml-4
a(target="_blanck", href="/static/privacy") {{ $t('privacy') }}
span.ml-4
a(target="_blanck", href="/static/terms") {{ $t('terms') }}
</template>
<style lang="scss" scoped>
.footer-row {
margin: 0;
flex: 0 1 auto;
z-index: 17;
}
footer {
color: #c3c0c7;
padding-bottom: 3em;
a {
color: #2995cd;
}
}
h3 {
color: #878190;
}
ul {
padding-left: 0;
list-style-type: none;
}
li {
margin-bottom: .5em;
}
.social {
h3 {
text-align: right;
}
}
.icons {
display: flex;
justify-content: flex-end;
flex-shrink: 1;
}
// smaller than desktop
@media only screen and (max-width: 992px) {
.social-circle {
height: 32px !important;
width: 32px !important;
margin-left: 0.75em !important;
}
}
.social-circle {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #c3c0c7;
display: flex;
margin-left: 1em;
&:first-child {
margin-left: 0;
}
&:hover {
background-color: #a5a1ac;
}
.social-icon {
color: #e1e0e3;
width: 16px;
margin: auto;
}
}
.logo {
width: 24px;
height: 24px;
margin: 0 auto;
color: #e1e0e3;
}
.debug-group {
position: absolute;
background: #fff;
top: -300px;
border-radius: 2px;
padding: 2em;
}
.btn-contribute {
background: #c3c0c7;
box-shadow: none;
border-radius: 4px;
&:hover {
background: #a5a1ac;
.text {
color: white;
}
}
a {
display: flex;
}
.heart {
max-height: 25px;
width: 18px;
margin-right: .5em;
margin-bottom: .2em;
}
.text, .heart {
display: inline-block;
vertical-align: bottom;
}
}
</style>
<style lang="scss">
.heart svg {
margin-top: .1em;
}
.facebook svg {
width: 10px;
margin: 0 auto;
}
footer {
&.expanded {
padding-left: 6em;
padding-right: 6em;
padding-top: 3em;
background: #e1e0e3;
color: #878190;
min-height: 408px;
a {
color: #878190;
}
.logo {
color: #c3c0c7;
}
}
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import { mapState } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import gryphon from 'assets/svg/gryphon.svg';
import twitter from 'assets/svg/twitter.svg';
import facebook from 'assets/svg/facebook.svg';
import instagram from 'assets/svg/instagram.svg';
import heart from 'assets/svg/heart.svg';
import modifyInventory from './modifyInventory';
import buyGemsModal from './payments/buyGemsModal';
const IS_PRODUCTION = process.env.NODE_ENV === 'production'; // eslint-disable-line no-process-env
export default {
components: {
modifyInventory,
buyGemsModal,
},
data () {
return {
icons: Object.freeze({
gryphon,
twitter,
facebook,
instagram,
heart,
}),
debugMenuShown: false,
IS_PRODUCTION,
};
},
computed: {
...mapState({user: 'user.data'}),
...mapState(['isUserLoaded']),
getDataDisplayToolUrl () {
const base = 'https://oldgods.net/habitrpg/habitrpg_user_data_display.html';
if (!this.user) return;
return `${base}?uuid=${this.user._id}`;
},
},
methods: {
plusTenHealth () {
this.$store.dispatch('user:set', {
'stats.hp': this.user.stats.hp += 10,
});
},
setHealthLow () {
this.$store.dispatch('user:set', {
'stats.hp': 1,
});
},
async addMissedDay (numberOfDays) {
if (!confirm(`Are you sure you want to reset the day by ${numberOfDays} day(s)?`)) return;
let date = moment(this.user.lastCron).subtract(numberOfDays, 'days').toDate();
await axios.post('/api/v4/debug/set-cron', {
lastCron: date,
});
// @TODO: Notification.text('-' + numberOfDays + ' day(s), remember to refresh');
// @TODO: Sync user?
},
async addTenGems () {
await axios.post('/api/v4/debug/add-ten-gems');
// @TODO: Notification.text('+10 Gems!');
this.user.balance += 2.5;
},
async addHourglass () {
await axios.post('/api/v4/debug/add-hourglass');
// @TODO: Sync?
},
addGold () {
this.$store.dispatch('user:set', {
'stats.gp': this.user.stats.gp + 500,
});
},
addMana () {
this.$store.dispatch('user:set', {
'stats.mp': this.user.stats.mp + 500,
});
},
addLevelsAndGold () {
this.$store.dispatch('user:set', {
'stats.exp': this.user.stats.exp + 10000,
'stats.gp': this.user.stats.gp + 10000,
'stats.mp': this.user.stats.mp + 10000,
});
},
addExp () {
// @TODO: Name these variables better
let exp = 0;
let five = 10 * this.user.stats.lvl;
let four = Math.pow(this.user.stats.lvl, 2) * 0.25;
let three = four + five + 139.75;
let two = three / 10;
let one = Math.round(two) * 10;
exp = this.user.stats.exp + one;
this.$store.dispatch('user:set', {
'stats.exp': exp,
});
},
addOneLevel () {
this.$store.dispatch('user:set', {
'stats.lvl': this.user.stats.lvl + 1,
});
},
async addQuestProgress () {
await axios.post('/api/v4/debug/quest-progress');
// @TODO: Notification.text('Quest progress increased');
// @TODO: User.sync();
},
async makeAdmin () {
await axios.post('/api/v4/debug/make-admin');
// @TODO: Notification.text('You are now an admin! Go to the Hall of Heroes to change your contributor level.');
// @TODO: sync()
},
openModifyInventoryModal () {
this.$root.$emit('bv::show::modal', 'modify-inventory');
},
donate () {
Analytics.track({
hitType: 'event',
eventCategory: 'button',
eventAction: 'click',
eventLabel: 'Gems > Donate',
});
this.$root.$emit('bv::show::modal', 'buy-gems', {alreadyTracked: true});
},
},
};
</script>
@@ -0,0 +1,221 @@
<template lang="pug">
.form
.form-group.row.text-center
.col-12.col-md-6
.btn.btn-secondary.social-button(@click='socialAuth("facebook")')
.svg-icon.social-icon(v-html="icons.facebookIcon")
span {{registering ? $t('signUpWithSocial', {social: 'Facebook'}) : $t('loginWithSocial', {social: 'Facebook'})}}
.col-12.col-md-6
.btn.btn-secondary.social-button(@click='socialAuth("google")')
.svg-icon.social-icon(v-html="icons.googleIcon")
span {{registering ? $t('signUpWithSocial', {social: 'Google'}) : $t('loginWithSocial', {social: 'Google'})}}
.form-group(v-if='registering')
label(for='usernameInput', v-once) {{$t('username')}}
input#usernameInput.form-control(type='text', :placeholder='$t("usernamePlaceholder")', v-model='username', :class='{"input-valid": usernameValid, "input-invalid": usernameInvalid}')
.form-group(v-if='!registering')
label(for='usernameInput', v-once) {{$t('emailOrUsername')}}
input#usernameInput.form-control(type='text', :placeholder='$t("emailOrUsername")', v-model='username')
.form-group(v-if='registering')
label(for='emailInput', v-once) {{$t('email')}}
input#emailInput.form-control(type='email', :placeholder='$t("emailPlaceholder")', v-model='email', :class='{"input-invalid": emailInvalid, "input-valid": emailValid}')
.form-group
label(for='passwordInput', v-once) {{$t('password')}}
a.float-right.forgot-password(v-once, v-if='!registering', @click='forgotPassword = true') {{$t('forgotPassword')}}
input#passwordInput.form-control(type='password', :placeholder='$t(registering ? "passwordPlaceholder" : "password")', v-model='password')
.form-group(v-if='registering')
label(for='confirmPasswordInput', v-once) {{$t('confirmPassword')}}
input#confirmPasswordInput.form-control(type='password', :placeholder='$t("confirmPasswordPlaceholder")', v-model='passwordConfirm', :class='{"input-invalid": passwordConfirmInvalid, "input-valid": passwordConfirmValid}')
small.form-text(v-once, v-html="$t('termsAndAgreement')")
.text-center
.btn.btn-info(@click='register()', v-if='registering', v-once) {{$t('joinHabitica')}}
.btn.btn-info(@click='login()', v-if='!registering', v-once) {{$t('login')}}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.form {
margin: 0 auto;
width: 100%;
padding-top: 2em;
padding-bottom: 4em;
position: relative;
z-index: 1;
.form-group {
text-align: left;
font-weight: bold;
}
.social-button {
width: 100%;
height: 100%;
white-space: inherit;
text-align: center;
.text {
display: inline-block;
min-height: 0px;
}
}
.social-icon {
margin-right: 1em;
width: 18px;
height: 18px;
display: inline-block;
vertical-align: top;
margin-top: .2em;
}
small.form-text {
text-align: center;
}
.input-valid {
color: #fff;
}
}
</style>
<script>
import hello from 'hellojs';
import { setUpAxios } from 'client/libs/auth';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import facebookSquareIcon from 'assets/svg/facebook-square.svg';
import googleIcon from 'assets/svg/google.svg';
export default {
name: 'AuthForm',
data () {
let data = {
registering: true,
username: '',
email: '',
password: '',
passwordConfirm: '',
usernameIssues: [],
};
data.icons = Object.freeze({
facebookIcon: facebookSquareIcon,
googleIcon,
});
return data;
},
mounted () {
hello.init({
facebook: process.env.FACEBOOK_KEY, // eslint-disable-line
// windows: WINDOWS_CLIENT_ID,
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},
computed: {
emailValid () {
if (this.email.length <= 3) return false;
return isEmail(this.email);
},
emailInvalid () {
return !this.emailValid;
},
usernameValid () {
if (this.username.length < 1) return false;
return this.usernameIssues.length === 0;
},
usernameInvalid () {
return !this.usernameValid;
},
passwordConfirmValid () {
if (this.passwordConfirm.length <= 3) return false;
return this.passwordConfirm === this.password;
},
passwordConfirmInvalid () {
return !this.passwordConfirmValid;
},
},
watch: {
username () {
this.validateUsername(this.username);
},
},
methods: {
// eslint-disable-next-line func-names
validateUsername: debounce(function (username) {
if (username.length < 1) {
return;
}
this.$store.dispatch('auth:verifyUsername', {
username: this.username,
}).then(res => {
if (res.issues !== undefined) {
this.usernameIssues = res.issues;
} else {
this.usernameIssues = [];
}
});
}, 500),
// @TODO: Abstract hello in to action or lib
async socialAuth (network) {
try {
await hello(network).logout();
} catch (e) {} // eslint-disable-line
try {
const redirectUrl = `${window.location.protocol}//${window.location.host}`;
let auth = await hello(network).login({
scope: 'email',
redirect_uri: redirectUrl, // eslint-disable-line camelcase
});
await this.$store.dispatch('auth:socialAuth', {
auth,
});
await this.finishAuth();
} catch (err) {
console.error(err); // eslint-disable-line no-console
// logout the user
await hello(network).logout();
this.socialAuth(network); // login again
}
},
async register () {
if (!this.email) {
alert(this.$t('missingEmail'));
return;
}
if (this.password !== this.passwordConfirm) {
alert(this.$t('passwordConfirmationMatch'));
return;
}
try {
await this.$store.dispatch('auth:register', {
username: this.username,
email: this.email,
password: this.password,
passwordConfirm: this.passwordConfirm,
});
await this.finishAuth();
} catch (e) {
if (e.response.data.data && e.response.data.data.errors) {
const message = e.response.data.data.errors.map(error => `${error.message}\n`);
alert(message);
}
}
},
async finishAuth () {
setUpAxios();
await this.$store.dispatch('user:fetch', {forceLoad: true});
this.$emit('authenticate');
},
},
};
</script>
@@ -0,0 +1,13 @@
<script>
export default {
components: {},
methods: {
async logout () {
return await this.$store.dispatch('auth:logout');
},
},
created () {
this.logout();
},
};
</script>
@@ -0,0 +1,575 @@
<template lang="pug">
.form-wrapper
#top-background
.seamless_stars_varied_opacity_repeat
form#login-form(
@submit.prevent='handleSubmit',
@keyup.enter="handleSubmit",
v-if="!forgotPassword && !resetPasswordSetNewOne",
)
.text-center
div
.svg-icon.gryphon
div
.svg-icon.habitica-logo(v-html="icons.habiticaIcon")
.form-group.row.text-center
.col-12.col-md-6
.btn.btn-secondary.social-button(@click='socialAuth("facebook")')
.svg-icon.social-icon(v-html="icons.facebookIcon")
.text {{registering ? $t('signUpWithSocial', {social: 'Facebook'}) : $t('loginWithSocial', {social: 'Facebook'})}}
.col-12.col-md-6
.btn.btn-secondary.social-button(@click='socialAuth("google")')
.svg-icon.social-icon(v-html="icons.googleIcon")
.text {{registering ? $t('signUpWithSocial', {social: 'Google'}) : $t('loginWithSocial', {social: 'Google'})}}
.form-group(v-if='registering')
label(for='usernameInput', v-once) {{$t('username')}}
input#usernameInput.form-control(type='text', :placeholder='$t("usernamePlaceholder")', v-model='username', :class='{"input-valid": usernameValid, "input-invalid": usernameInvalid}')
.input-error(v-for="issue in usernameIssues") {{ issue }}
.form-group(v-if='!registering')
label(for='usernameInput', v-once) {{$t('emailOrUsername')}}
input#usernameInput.form-control(type='text', :placeholder='$t("emailOrUsername")', v-model='username')
.form-group(v-if='registering')
label(for='emailInput', v-once) {{$t('email')}}
input#emailInput.form-control(type='email', :placeholder='$t("emailPlaceholder")', v-model='email', :class='{"input-invalid": emailInvalid, "input-valid": emailValid}')
.form-group
label(for='passwordInput', v-once) {{$t('password')}}
a.float-right.forgot-password(v-once, v-if='!registering', @click='forgotPassword = true') {{$t('forgotPassword')}}
input#passwordInput.form-control(type='password', :placeholder='$t(registering ? "passwordPlaceholder" : "password")', v-model='password')
.form-group(v-if='registering')
label(for='confirmPasswordInput', v-once) {{$t('confirmPassword')}}
input#confirmPasswordInput.form-control(type='password', :placeholder='$t("confirmPasswordPlaceholder")', v-model='passwordConfirm', :class='{"input-invalid": passwordConfirmInvalid, "input-valid": passwordConfirmValid}')
small.form-text(v-once, v-html="$t('termsAndAgreement')")
.text-center
.btn.btn-info(@click='register()', v-if='registering', v-once) {{$t('joinHabitica')}}
.btn.btn-info(@click='login()', v-if='!registering', v-once) {{$t('login')}}
.toggle-links
router-link(:to="{name: 'login'}", v-if='registering', exact)
a.toggle-link(v-once, v-html="$t('alreadyHaveAccountLogin')")
router-link(:to="{name: 'register'}", v-if='!registering', exact)
a.toggle-link(v-once, v-html="$t('dontHaveAccountSignup')")
form#forgot-form(v-on:submit.prevent='handleSubmit', @keyup.enter="handleSubmit", v-if='forgotPassword')
.text-center
div
.svg-icon.gryphon
div
.svg-icon.habitica-logo(v-html="icons.habiticaIcon")
.header
h2(v-once) {{ $t('emailNewPass') }}
p(v-once) {{ $t('forgotPasswordSteps') }}
.form-group.row.text-center
label(for='usernameInput', v-once) {{$t('email')}}
input#usernameInput.form-control(type='text', :placeholder='$t("emailPlaceholder")', v-model='username')
.text-center
.btn.btn-info(@click='forgotPasswordLink()', v-once) {{$t('sendLink')}}
form#reset-password-set-new-one-form(v-on:submit.prevent='handleSubmit', @keyup.enter="handleSubmit", v-if='resetPasswordSetNewOne')
.text-center
div
.svg-icon.gryphon
div
.svg-icon.habitica-logo(v-html="icons.habiticaIcon")
.header
h2 {{ $t('passwordResetPage') }}
.form-group
label(for='passwordInput', v-once) {{$t('newPass')}}
input#passwordInput.form-control(type='password', :placeholder="$t('password')", v-model='password')
.form-group
label(for='confirmPasswordInput', v-once) {{$t('confirmPass')}}
input#confirmPasswordInput.form-control(type='password', :placeholder='$t("confirmPasswordPlaceholder")', v-model='passwordConfirm')
.text-center
.btn.btn-info(
@click='resetPasswordSetNewOneLink()',
:enabled="!resetPasswordSetNewOneData.hasError"
) {{$t('setNewPass')}}
#bottom-wrap(:class="`bottom-wrap-${!registering ? 'login' : 'register'}`")
#bottom-background
.seamless_mountains_demo_repeat
.midground_foreground_extended2
</template>
<style>
html, body, #app {
min-height: 100%;
}
small a, small a:hover {
color: #fff;
text-decoration: underline;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@media only screen and (min-height: 1080px) {
.bottom-wrap-register {
margin-top: 6em;
position: fixed !important;
width: 100%;
bottom: 0;
}
}
@media only screen and (min-height: 862px) {
.bottom-wrap-login {
margin-top: 6em;
position: fixed !important;
width: 100%;
bottom: 0;
}
}
@media only screen and (max-width: 768px) {
#login-form {
width: 100% !important;
}
.form-group {
padding-left: .5em;
padding-right: .5em;
}
}
.form-wrapper {
background-color: $purple-200;
background: $purple-200; /* For browsers that do not support gradients */
background: linear-gradient(to bottom, #4f2a93, #6133b4); /* Standard syntax */
min-height: 100vh;
}
::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $purple-400;
}
::-moz-placeholder { /* Firefox 19+ */
color: $purple-400;
}
:-ms-input-placeholder { /* IE 10+ */
color: $purple-400;
}
:-moz-placeholder { /* Firefox 18- */
color: $purple-400;
}
::placeholder { // Standard browsers
color: $purple-400;
}
#login-form, #forgot-form, #reset-password-set-new-one-form {
margin: 0 auto;
width: 40em;
padding-top: 5em;
padding-bottom: 4em;
position: relative;
z-index: 1;
.header {
h2 {
color: $white;
}
color: $white;
}
.gryphon {
background-image: url('~assets/images/melior@3x.png');
width: 63.2px;
height: 69.4px;
background-size: cover;
color: $white;
margin: 0 auto;
}
.habitica-logo {
width: 144px;
height: 31px;
margin: 2em auto;
}
label {
color: $white;
font-weight: bold;
}
input {
margin-bottom: 2em;
border-radius: 2px;
background-color: #432874;
border-color: transparent;
height: 50px;
color: $white;
}
#usernameInput.input-invalid {
margin-bottom: 0.5em;
}
.form-text {
font-size: 14px;
color: $white;
}
.social-button {
width: 100%;
height: 100%;
white-space: inherit;
text-align: center;
.text {
display: inline-block;
}
}
.social-icon {
margin-right: 1em;
width: 18px;
height: 18px;
display: inline-block;
vertical-align: top;
margin-top: .2em;
}
}
#top-background {
.seamless_stars_varied_opacity_repeat {
background-image: url('~assets/images/auth/seamless_stars_varied_opacity.png');
background-repeat: repeat-x;
position: absolute;
height: 500px;
width: 100%;
}
}
#bottom-wrap {
margin-top: 6em;
position: static;
width: 100%;
bottom: 0;
}
#bottom-background {
position: relative;
.seamless_mountains_demo_repeat {
background-image: url('~assets/images/auth/seamless_mountains_demo.png');
background-repeat: repeat-x;
width: 100%;
height: 500px;
position: absolute;
z-index: 0;
bottom: 0;
}
.midground_foreground_extended2 {
background-image: url('~assets/images/auth/midground_foreground_extended2.png');
position: relative;
width: 1500px;
max-width: 100%;
height: 150px;
margin: 0 auto;
}
}
.toggle-links {
margin-top: 1em;
}
.toggle-link {
color: $white !important;
}
.forgot-password {
color: #bda8ff !important;
}
.input-error {
color: #fff;
font-size: 90%;
width: 100%;
text-align: center;
}
</style>
<script>
import axios from 'axios';
import hello from 'hellojs';
import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import gryphon from 'assets/svg/gryphon.svg';
import habiticaIcon from 'assets/svg/habitica-logo.svg';
import facebookSquareIcon from 'assets/svg/facebook-square.svg';
import googleIcon from 'assets/svg/google.svg';
export default {
data () {
let data = {
username: '',
email: '',
password: '',
passwordConfirm: '',
forgotPassword: false,
resetPasswordSetNewOneData: {
hasError: null,
code: null,
},
usernameIssues: [],
};
data.icons = Object.freeze({
gryphon,
habiticaIcon,
facebookIcon: facebookSquareIcon,
googleIcon,
});
return data;
},
computed: {
registering () {
if (this.$route.path.startsWith('/register')) {
return true;
}
return false;
},
resetPasswordSetNewOne () {
if (this.$route.path.startsWith('/reset-password')) {
return true;
}
return false;
},
emailValid () {
if (this.email.length <= 3) return false;
return isEmail(this.email);
},
emailInvalid () {
if (this.email.length <= 3) return false;
return !this.emailValid;
},
usernameValid () {
if (this.username.length < 1) return false;
return this.usernameIssues.length === 0;
},
usernameInvalid () {
if (this.username.length < 1) return false;
return !this.usernameValid;
},
passwordConfirmValid () {
if (this.passwordConfirm.length <= 3) return false;
return this.passwordConfirm === this.password;
},
passwordConfirmInvalid () {
if (this.passwordConfirm.length <= 3) return false;
return !this.passwordConfirmValid;
},
},
mounted () {
hello.init({
facebook: process.env.FACEBOOK_KEY, // eslint-disable-line
// windows: WINDOWS_CLIENT_ID,
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},
watch: {
$route: {
handler () {
if (this.resetPasswordSetNewOne) {
const query = this.$route.query;
const code = query.code;
const hasError = query.hasError === 'true' ? true : false;
if (hasError) {
alert(query.message);
this.$router.push({name: 'login'});
return;
}
if (!code) {
alert(this.$t('invalidPasswordResetCode'));
this.$router.push({name: 'login'});
return;
}
this.resetPasswordSetNewOneData.code = query.code;
this.resetPasswordSetNewOneData.hasError = hasError;
}
},
immediate: true,
},
username () {
this.validateUsername(this.username);
},
},
methods: {
// eslint-disable-next-line func-names
validateUsername: debounce(function (username) {
if (username.length <= 3 || !this.registering) {
return;
}
this.$store.dispatch('auth:verifyUsername', {
username: this.username,
}).then(res => {
if (res.issues !== undefined) {
this.usernameIssues = res.issues;
} else {
this.usernameIssues = [];
}
});
}, 500),
async register () {
// @TODO do not use alert
if (!this.email) {
alert(this.$t('missingEmail'));
return;
}
if (this.password !== this.passwordConfirm) {
alert(this.$t('passwordConfirmationMatch'));
return;
}
// @TODO: implement langauge and invite accepting
// var url = ApiUrl.get() + "/api/v4/user/auth/local/register";
// if (location.search && location.search.indexOf('Invite=') !== -1) { // matches groupInvite and partyInvite
// url += location.search;
// }
//
// if($rootScope.selectedLanguage) {
// var toAppend = url.indexOf('?') !== -1 ? '&' : '?';
// url = url + toAppend + 'lang=' + $rootScope.selectedLanguage.code;
// }
await this.$store.dispatch('auth:register', {
username: this.username,
email: this.email,
password: this.password,
passwordConfirm: this.passwordConfirm,
});
let redirectTo;
if (this.$route.query.redirectTo) {
redirectTo = this.$route.query.redirectTo;
} else {
redirectTo = '/';
}
// @TODO do not reload entire page
// problem is that app.vue created hook should be called again
// after user is logged in / just signed up
// ALSO it's the only way to make sure language data is reloaded and correct for the logged in user
window.location.href = redirectTo;
},
async login () {
await this.$store.dispatch('auth:login', {
username: this.username,
// email: this.email,
password: this.password,
});
let redirectTo;
if (this.$route.query.redirectTo) {
redirectTo = this.$route.query.redirectTo;
} else {
redirectTo = '/';
}
// @TODO do not reload entire page
// problem is that app.vue created hook should be called again
// after user is logged in / just signed up
// ALSO it's the only way to make sure language data is reloaded and correct for the logged in user
window.location.href = redirectTo;
},
// @TODO: Abstract hello in to action or lib
async socialAuth (network) {
try {
await hello(network).logout();
} catch (e) {} // eslint-disable-line
const redirectUrl = `${window.location.protocol}//${window.location.host}`;
let auth = await hello(network).login({
scope: 'email',
// explicitly pass the redirect url or it might redirect to /home
redirect_uri: redirectUrl, // eslint-disable-line camelcase
});
await this.$store.dispatch('auth:socialAuth', {
auth,
});
let redirectTo;
if (this.$route.query.redirectTo) {
redirectTo = this.$route.query.redirectTo;
} else {
redirectTo = '/';
}
// @TODO do not reload entire page
// problem is that app.vue created hook should be called again
// after user is logged in / just signed up
// ALSO it's the only way to make sure language data is reloaded and correct for the logged in user
window.location.href = redirectTo;
},
handleSubmit () {
if (this.registering) {
this.register();
return;
}
if (this.forgotPassword) {
this.forgotPasswordLink();
return;
}
if (this.resetPasswordSetNewOne) {
this.resetPasswordSetNewOneLink();
return;
}
this.login();
},
async forgotPasswordLink () {
if (!this.username) {
alert(this.$t('missingEmail'));
return;
}
await axios.post('/api/v4/user/reset-password', {
email: this.username,
});
alert(this.$t('newPassSent'));
},
async resetPasswordSetNewOneLink () {
if (!this.password) {
alert(this.$t('missingNewPassword'));
return;
}
if (this.password !== this.passwordConfirm) {
// @TODO i18n and don't use alerts
alert(this.$t('passwordConfirmationMatch'));
return;
}
const res = await axios.post('/api/v4/user/auth/reset-password-set-new-one', {
newPassword: this.password,
confirmPassword: this.passwordConfirm,
code: this.resetPasswordSetNewOneData.code,
});
if (res.data.message) {
alert(res.data.message);
}
this.password = '';
this.passwordConfirm = '';
this.resetPasswordSetNewOneData.code = '';
this.resetPasswordSetNewOneData.hasError = false;
this.$router.push({name: 'login'});
},
},
};
</script>
+228
View File
@@ -0,0 +1,228 @@
<template lang="pug">
.avatar(:style="{width, height, paddingTop}", :class="backgroundClass", @click.prevent='castEnd()')
.character-sprites(:style='{margin: spritesMargin}')
template(v-if="!avatarOnly")
// Mount Body
span(v-if="member.items.currentMount", :class="'Mount_Body_' + member.items.currentMount")
// Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc
template(v-for="(klass, item) in visualBuffs")
span(v-if="member.stats.buffs[item] && showVisualBuffs", :class="klass")
// Show flower ALL THE TIME!!!
// See https://github.com/HabitRPG/habitica/issues/7133
span(:class="'hair_flower_' + member.preferences.hair.flower")
// Show avatar only if not currently affected by visual buff
template(v-if="showAvatar()")
span(:class="['chair_' + member.preferences.chair, specialMountClass]")
span(:class="[getGearClass('back'), specialMountClass]")
span(:class="[skinClass, specialMountClass]")
span(:class="[member.preferences.size + '_shirt_' + member.preferences.shirt, specialMountClass]")
span(:class="['head_0', specialMountClass]")
span(:class="[member.preferences.size + '_' + getGearClass('armor'), specialMountClass]")
span(:class="[getGearClass('back_collar'), specialMountClass]")
template(v-for="type in ['bangs', 'base', 'mustache', 'beard']")
span(:class="['hair_' + type + '_' + member.preferences.hair[type] + '_' + member.preferences.hair.color, specialMountClass]")
span(:class="[getGearClass('body'), specialMountClass]")
span(:class="[getGearClass('eyewear'), specialMountClass]")
span(:class="[getGearClass('head'), specialMountClass]")
span(:class="[getGearClass('headAccessory'), specialMountClass]")
span(:class="['hair_flower_' + member.preferences.hair.flower, specialMountClass]")
span(v-if="!hideGear('shield')", :class="[getGearClass('shield'), specialMountClass]")
span(v-if="!hideGear('weapon')", :class="[getGearClass('weapon'), specialMountClass]")
// Resting
span.zzz(v-if="member.preferences.sleep")
template(v-if="!avatarOnly")
// Mount Head
span(v-if="member.items.currentMount", :class="'Mount_Head_' + member.items.currentMount")
// Pet
span.current-pet(v-if="member.items.currentPet", :class="'Pet-' + member.items.currentPet")
class-badge.under-avatar(v-if="hasClass && !hideClassBadge", :member-class="member.stats.class")
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.avatar {
width: 140px;
height: 147px;
image-rendering: pixelated;
position: relative;
cursor: pointer;
}
.character-sprites {
width: 90px;
height: 90px;
}
.character-sprites span {
position: absolute;
}
.current-pet {
bottom: 0px;
left: 0px;
}
.offset-kangaroo {
margin-top: 24px;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import ClassBadge from 'client/components/members/classBadge';
export default {
components: {
ClassBadge,
},
props: {
member: {
type: Object,
required: true,
},
avatarOnly: {
type: Boolean,
default: false,
},
hideClassBadge: {
type: Boolean,
default: false,
},
withBackground: {
type: Boolean,
},
overrideAvatarGear: {
type: Object,
},
width: {
type: Number,
default: 140,
},
height: {
type: Number,
default: 147,
},
spritesMargin: {
type: String,
default: '0 auto 0 24px',
},
overrideTopPadding: {
type: String,
},
showVisualBuffs: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
flatGear: 'content.gear.flat',
}),
hasClass () {
return this.$store.getters['members:hasClass'](this.member);
},
isBuffed () {
return this.$store.getters['members:isBuffed'](this.member);
},
paddingTop () {
if (this.overrideTopPadding) {
return this.overrideTopPadding;
}
let val = '28px';
if (!this.avatarOnly) {
if (this.member.items.currentPet) val = '24px';
if (this.member.items.currentMount) val = '0px';
}
return val;
},
backgroundClass () {
let background = this.member.preferences.background;
let allowToShowBackground = !this.avatarOnly || this.withBackground;
if (this.overrideAvatarGear && this.overrideAvatarGear.background) {
return `background_${this.overrideAvatarGear.background}`;
}
if (background && allowToShowBackground) {
return `background_${this.member.preferences.background}`;
}
return '';
},
visualBuffs () {
return {
snowball: 'snowman',
spookySparkles: 'ghost',
shinySeed: `avatar_floral_${this.member.stats.class}`,
seafoam: 'seafoam_star',
};
},
skinClass () {
let baseClass = `skin_${this.member.preferences.skin}`;
return `${baseClass}${this.member.preferences.sleep ? '_sleep' : ''}`;
},
costumeClass () {
return this.member.preferences.costume ? 'costume' : 'equipped';
},
specialMountClass () {
if (!this.avatarOnly && this.member.items.currentMount && this.member.items.currentMount.indexOf('Kangaroo') !== -1) {
return 'offset-kangaroo';
}
},
},
methods: {
getGearClass (gearType) {
let result = this.member.items.gear[this.costumeClass][gearType];
if (this.overrideAvatarGear && this.overrideAvatarGear[gearType]) {
result = this.overrideAvatarGear[gearType];
}
return result;
},
hideGear (gearType) {
if (gearType === 'weapon') {
let equippedWeapon = this.member.items.gear[this.costumeClass][gearType];
if (!equippedWeapon) {
return false;
}
let equippedIsTwoHanded = this.flatGear[equippedWeapon].twoHanded;
let hasOverrideShield = this.overrideAvatarGear && this.overrideAvatarGear.shield;
return equippedIsTwoHanded && hasOverrideShield;
} else if (gearType === 'shield') {
let overrideWeapon = this.overrideAvatarGear && this.overrideAvatarGear.weapon;
let overrideIsTwoHanded = overrideWeapon && this.flatGear[overrideWeapon].twoHanded;
return overrideIsTwoHanded;
}
},
castEnd (e) {
if (!this.$store.state.spellOptions.castingSpell) return;
this.$root.$emit('castEnd', this.member, 'user', e);
},
showAvatar () {
if (!this.showVisualBuffs)
return true;
let buffs = this.member.stats.buffs;
return !buffs.snowball && !buffs.spookySparkles && !buffs.shinySeed && !buffs.seafoam;
},
},
};
</script>
@@ -0,0 +1,92 @@
<template lang="pug">
#body.section.customize-section
sub-menu.text-center(:items="items", :activeSubPage="activeSubPage", @changeSubPage="changeSubPage($event)")
div(v-if='activeSubPage === "size"')
customize-options(
:items="sizes",
:currentValue="user.preferences.size"
)
div(v-if='activeSubPage === "shirt"')
customize-options(
:items="freeShirts",
:currentValue="user.preferences.shirt"
)
customize-options(
v-if='editing',
:items='specialShirts',
:currentValue="user.preferences.shirt",
:fullSet='!userOwnsSet("shirt", specialShirtKeys)',
@unlock='unlock(`shirt.${specialShirtKeys.join(",shirt.")}`)'
)
</template>
<script>
import appearance from 'common/script/content/appearance';
import {subPageMixin} from '../../mixins/subPage';
import {userStateMixin} from '../../mixins/userState';
import {avatarEditorUtilies} from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import customizeOptions from './customize-options';
import gem from 'assets/svg/gem.svg';
const freeShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price === 0);
const specialShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price !== 0);
export default {
props: [
'editing',
],
components: {
subMenu,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
],
data () {
return {
specialShirtKeys,
icons: Object.freeze({
gem,
}),
items: [
{
id: 'size',
label: this.$t('size'),
},
{
id: 'shirt',
label: this.$t('shirt'),
},
],
};
},
computed: {
sizes () {
return ['slim', 'broad'].map(s => this.mapKeysToFreeOption(s, 'size'));
},
freeShirts () {
return freeShirtKeys.map(s => this.mapKeysToFreeOption(s, 'shirt'));
},
specialShirts () {
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.specialShirtKeys;
let options = keys.map(key => this.mapKeysToOption(key, 'shirt'));
return options;
},
},
mounted () {
this.changeSubPage('size');
},
methods: {
},
};
</script>
<style scoped>
</style>
@@ -0,0 +1,304 @@
<template lang="pug">
.customize-options(:class="{'background-set': fullSet}")
.outer-option-background(
v-for='option in items',
:key='option.key',
@click='option.click(option)',
:class='{locked: option.gemLocked || option.goldLocked, premium: Boolean(option.gem), active: option.active || currentValue === option.key, none: option.none, hide: option.hide }'
)
.option
.sprite.customize-option(:class='option.class')
.redline-outer(v-if="option.none")
.redline
.gem-lock(v-if='option.gemLocked')
.svg-icon.gem(v-html='icons.gem')
span {{ option.gem }}
.gold-lock(v-if='option.goldLocked')
.svg-icon.gold(v-html='icons.gold')
span {{ option.gold }}
.purchase-set(v-if='fullSet', @click='unlock()')
span.label {{ $t('purchaseAll') }}
.svg-icon.gem(v-html='icons.gem')
span.price 5
</template>
<script>
import gem from 'assets/svg/gem.svg';
import gold from 'assets/svg/gold.svg';
import {avatarEditorUtilies} from '../../mixins/avatarEditUtilities';
export default {
props: ['items', 'currentValue', 'fullSet'],
mixins: [
avatarEditorUtilies,
],
data () {
return {
icons: Object.freeze({
gem,
gold,
}),
};
},
methods: {
unlock () {
this.$emit('unlock');
},
},
};
</script>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.customize-options {
width: 100%;
}
.hide {
display: none !important;
}
.outer-option-background {
display: inline-block;
vertical-align: top;
pointer-events: visible;
cursor: pointer;
&.premium {
height: 112px;
width: 96px;
margin-left: 8px;
margin-right: 8px;
margin-bottom: 8px;
.option {
margin: 12px 16px;
}
}
&.locked {
border-radius: 2px;
border: 1px solid transparent;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
background-color: $white;
.option {
border: none;
border-radius: 2px;
padding-left: 6px;
padding-top: 4px;
}
&:hover {
box-shadow: 0 4px 4px 0 rgba($black, 0.16), 0 1px 8px 0 rgba($black, 0.12);
border: 1px solid $purple-500;
}
}
&:not(.locked):not(.active) {
.option:hover {
background-color: rgba(213, 200, 255, .32);
}
}
&.premium:not(.locked):not(.active) {
border-radius: 2px;
background-color: rgba(59, 202, 215, 0.1);
}
&.none .option {
.sprite {
opacity: 0.24;
}
.redline-outer {
height: 60px;
width: 60px;
position: absolute;
bottom: 0;
margin: 0 auto 0 0;
.redline {
width: 60px;
height: 4px;
display: block;
background: red;
transform: rotate(-45deg);
position: absolute;
top: 0;
margin-top: 30px;
margin-bottom: 20px;
margin-left: -1px;
}
}
}
&.active .option {
background: white;
border: solid 4px $purple-300;
}
&.premium:not(.active) .option {
border-radius: 8px;
}
}
.option {
vertical-align: bottom;
height: 64px;
width: 64px;
margin: 12px 8px;
border: 4px solid transparent;
border-radius: 10px;
position: relative;
&:hover {
cursor: pointer;
}
}
.outer-option-background:not(.none) {
.sprite.customize-option {
// margin: 0 auto;
//margin-left: -3px;
//margin-top: -7px;
margin-top: 0;
margin-left: 0;
&.size, &.shirt {
margin-top: -8px;
margin-left: -4px;
}
&.color-bangs {
margin-top: 3px;
}
&.skin {
margin-top: -4px;
margin-left: -4px;
}
&.chair {
margin-left: -1px;
margin-top: -1px;
&.button_chair_black {
// different sprite margin?
margin-top: -3px;
}
&.handleless {
margin-left: -5px;
margin-top: -5px;
}
}
&.color, &.bangs {
margin-top: 4px;
margin-left: -3px;
}
&.hair.base {
margin-top: 0px;
margin-left: -5px;
}
&.headAccessory {
margin-top: 0;
margin-left: -4px;
}
&.headband {
margin-top: -6px;
margin-left: -27px;
}
}
}
.text-center {
.gem-lock, .gold-lock {
display: inline-block;
margin: 0 auto 8px;
vertical-align: bottom;
}
}
.gem-lock, .gold-lock {
.svg-icon {
width: 16px;
}
span {
font-weight: bold;
margin-left: .5em;
}
.svg-icon, span {
display: inline-block;
vertical-align: bottom;
}
}
.gem-lock span {
color: $green-10
}
.purchase-set {
background: #fff;
padding: 0.5em;
border-radius: 0 0 2px 2px;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
cursor: pointer;
span {
font-weight: bold;
font-size: 12px;
}
span.price {
color: #24cc8f;
}
.gem, .coin {
width: 16px;
}
&.single {
width: 141px;
}
width: 100%;
span {
font-size: 14px;
}
.gem, .coin {
width: 20px;
margin: 0 .5em;
display: inline-block;
vertical-align: bottom;
}
}
.background-set {
background-color: #edecee;
border-radius: 2px;
padding-top: 12px;
margin-left: 12px;
margin-right: 12px;
margin-bottom: 12px;
width: calc(100% - 24px);
padding-left: 0;
padding-right: 0;
max-width: unset; // disable col12 styling
flex: unset;
}
</style>
@@ -0,0 +1,272 @@
<template lang="pug">
#extra.section.container.customize-section
sub-menu.text-center(:items="extraSubMenuItems", :activeSubPage="activeSubPage", @changeSubPage="changeSubPage($event)")
#hair-color(v-if='activeSubPage === "glasses"')
customize-options(
:items="eyewear"
)
#animal-ears(v-if='activeSubPage === "ears"')
customize-options(
:items="animalItems('headAccessory')",
:fullSet='!animalItemsOwned("headAccessory")',
@unlock='unlock(animalItemsUnlockString("headAccessory"))'
)
#animal-tails(v-if='activeSubPage === "tails"')
customize-options(
:items="animalItems('back')",
:fullSet='!animalItemsOwned("back")',
@unlock='unlock(animalItemsUnlockString("back"))'
)
#headband(v-if='activeSubPage === "headband"')
customize-options(
:items="headbands",
)
#wheelchairs(v-if='activeSubPage === "wheelchair"')
customize-options(
:items="chairs",
)
#flowers(v-if='activeSubPage === "flower"')
customize-options(
:items="flowers",
)
</template>
<script>
import appearance from 'common/script/content/appearance';
import {subPageMixin} from '../../mixins/subPage';
import {userStateMixin} from '../../mixins/userState';
import {avatarEditorUtilies} from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import customizeOptions from './customize-options';
import gem from 'assets/svg/gem.svg';
const freeShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price === 0);
const specialShirtKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price !== 0);
export default {
props: [
'editing',
],
components: {
subMenu,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
],
data () {
return {
animalItemKeys: {
back: ['bearTail', 'cactusTail', 'foxTail', 'lionTail', 'pandaTail', 'pigTail', 'tigerTail', 'wolfTail'],
headAccessory: ['bearEars', 'cactusEars', 'foxEars', 'lionEars', 'pandaEars', 'pigEars', 'tigerEars', 'wolfEars'],
},
chairKeys: ['none', 'black', 'blue', 'green', 'pink', 'red', 'yellow', 'handleless_black', 'handleless_blue', 'handleless_green', 'handleless_pink', 'handleless_red', 'handleless_yellow'],
specialShirtKeys,
icons: Object.freeze({
gem,
}),
items: [
{
id: 'size',
label: this.$t('size'),
},
{
id: 'shirt',
label: this.$t('shirt'),
},
],
};
},
computed: {
extraSubMenuItems () {
const items = [];
if (this.editing) {
items.push({
id: 'glasses',
label: this.$t('glasses'),
});
}
items.push(
{
id: 'wheelchair',
label: this.$t('wheelchair'),
},
{
id: 'flower',
label: this.$t('accent'),
},
);
if (this.editing) {
items.push({
id: 'ears',
label: this.$t('animalEars'),
});
items.push({
id: 'tails',
label: this.$t('animalTails'),
});
items.push({
id: 'headband',
label: this.$t('headband'),
});
}
return items;
},
eyewear () {
let keys = [
'blackTopFrame', 'blueTopFrame', 'greenTopFrame', 'pinkTopFrame', 'redTopFrame', 'whiteTopFrame', 'yellowTopFrame',
'blackHalfMoon', 'blueHalfMoon', 'greenHalfMoon', 'pinkHalfMoon', 'redHalfMoon', 'whiteHalfMoon', 'yellowHalfMoon',
];
let options = keys.map(key => {
let newKey = `eyewear_special_${key}`;
let option = {};
option.key = key;
option.active = this.user.preferences.costume ? this.user.items.gear.costume.eyewear === newKey : this.user.items.gear.equipped.eyewear === newKey;
option.class = `eyewear_special_${key}`;
option.click = () => {
let type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
};
return option;
});
return options;
},
freeShirts () {
return freeShirtKeys.map(s => this.mapKeysToFreeOption(s, 'shirt'));
},
specialShirts () {
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.specialShirtKeys;
let options = keys.map(key => this.mapKeysToOption(key, 'shirt'));
return options;
},
headbands () {
let keys = ['blackHeadband', 'blueHeadband', 'greenHeadband', 'pinkHeadband', 'redHeadband', 'whiteHeadband', 'yellowHeadband'];
let options = keys.map(key => {
let newKey = `headAccessory_special_${key}`;
let option = {};
option.key = key;
option.active = this.user.preferences.costume ? this.user.items.gear.costume.headAccessory === newKey : this.user.items.gear.equipped.headAccessory === newKey;
option.class = `headAccessory_special_${option.key} headband`;
option.click = () => {
let type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
};
return option;
});
return options;
},
chairs () {
let options = this.chairKeys.map(key => {
let option = {};
option.key = key;
if (key === 'none') {
option.none = true;
}
option.active = this.user.preferences.chair === key;
option.class = `button_chair_${key} chair ${key.includes('handleless_') ? 'handleless' : ''}`;
option.click = () => {
return this.set({'preferences.chair': key});
};
return option;
});
return options;
},
flowers () {
let keys = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
let options = keys.map(key => {
let option = {};
option.key = key;
if (key === 0) {
option.none = true;
}
option.active = this.user.preferences.hair.flower === key;
option.class = `hair_flower_${key} flower`;
option.click = () => {
return this.set({'preferences.hair.flower': key});
};
return option;
});
return options;
},
},
mounted () {
this.changeSubPage(this.extraSubMenuItems[0].id);
},
methods: {
animalItems (category) {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.animalItemKeys[category];
let options = keys.map(key => {
let newKey = `${category}_special_${key}`;
let userPurchased = this.user.items.gear.owned[newKey];
let option = {};
option.key = key;
option.active = this.user.preferences.costume ? this.user.items.gear.costume[category] === newKey : this.user.items.gear.equipped[category] === newKey;
option.class = `headAccessory_special_${option.key} ${category}`;
if (category === 'back') {
option.class = `icon_back_special_${option.key} back`;
}
option.gemLocked = userPurchased === undefined;
option.goldLocked = userPurchased === false;
if (option.goldLocked) {
option.gold = 20;
}
if (option.gemLocked) {
option.gem = 2;
}
option.locked = option.gemLocked || option.goldLocked;
option.click = () => {
if (option.gemLocked) {
return this.unlock(`items.gear.owned.${newKey}`);
} else if (option.goldLocked) {
return this.buy(newKey);
} else {
let type = this.user.preferences.costume ? 'costume' : 'equipped';
return this.equip(newKey, type);
}
};
return option;
});
return options;
},
animalItemsUnlockString (category) {
const keys = this.animalItemKeys[category].map(key => {
return `items.gear.owned.${category}_special_${key}`;
});
return keys.join(',');
},
animalItemsOwned (category) {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let own = true;
this.animalItemKeys[category].forEach(key => {
if (this.user.items.gear.owned[`${category}_special_${key}`] === undefined) own = false;
});
return own;
},
},
};
</script>
<style scoped>
</style>
@@ -0,0 +1,341 @@
<template lang="pug">
#hair.section.customize-section
sub-menu.text-center(:items="hairSubMenuItems", :activeSubPage="activeSubPage", @changeSubPage="changeSubPage($event)")
#hair-color(v-if='activeSubPage === "color"')
customize-options(
:items="freeHairColors",
:currentValue="user.preferences.hair.color"
)
div(v-if='editing && set.key !== "undefined"', v-for='set in seasonalHairColors')
customize-options(
:items='set.options',
:currentValue="user.preferences.skin",
:fullSet='!hideSet(set) && !userOwnsSet("hair", set.keys, "color")',
@unlock='unlock(`hair.color.${set.keys.join(",hair.color.")}`)'
)
#style(v-if='activeSubPage === "style"')
div(v-for='set in styleSets')
customize-options(
:items='set.options',
:fullSet='set.fullSet',
@unlock='set.unlock()'
)
#bangs(v-if='activeSubPage === "bangs"')
customize-options(
:items='hairBangs',
:currentValue="user.preferences.hair.bangs"
)
#facialhair(v-if='activeSubPage === "facialhair"')
customize-options(
v-if='editing',
:items='mustacheList'
)
customize-options(
v-if='editing',
:items='beardList',
:fullSet='isPurchaseAllNeeded("hair", ["baseHair5", "baseHair6"], ["mustache", "beard"])',
@unlock='unlock(`hair.mustache.${baseHair5Keys.join(",hair.mustache.")},hair.beard.${baseHair6Keys.join(",hair.beard.")}`)'
)
</template>
<script>
import appearance from 'common/script/content/appearance';
import {subPageMixin} from '../../mixins/subPage';
import {userStateMixin} from '../../mixins/userState';
import {avatarEditorUtilies} from '../../mixins/avatarEditUtilities';
import subMenu from './sub-menu';
import customizeOptions from './customize-options';
import gem from 'assets/svg/gem.svg';
import appearanceSets from 'common/script/content/appearance/sets';
import groupBy from 'lodash/groupBy';
const hairColorBySet = groupBy(appearance.hair.color, 'set.key');
const freeHairColorKeys = hairColorBySet[undefined].map(s => s.key);
export default {
props: [
'editing',
],
components: {
subMenu,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
],
data () {
return {
freeHairColorKeys,
icons: Object.freeze({
gem,
}),
baseHair1: [1, 3],
baseHair2Keys: [2, 4, 5, 6, 7, 8],
baseHair3Keys: [9, 10, 11, 12, 13, 14],
baseHair4Keys: [15, 16, 17, 18, 19, 20],
baseHair5Keys: [1, 2],
baseHair6Keys: [1, 2, 3],
};
},
computed: {
hairSubMenuItems () {
const items = [
{
id: 'color',
label: this.$t('color'),
},
{
id: 'bangs',
label: this.$t('bangs'),
},
{
id: 'style',
label: this.$t('style'),
},
];
if (this.editing) {
items.push({
id: 'facialhair',
label: this.$t('facialhair'),
});
}
return items;
},
freeHairColors () {
return freeHairColorKeys.map(s => this.mapKeysToFreeOption(s, 'hair', 'color'));
},
seasonalHairColors () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let seasonalHairColors = [];
for (let key in hairColorBySet) {
let set = hairColorBySet[key];
let keys = set.map(item => {
return item.key;
});
let options = keys.map(optionKey => {
const option = this.mapKeysToOption(optionKey, 'hair', 'color', key);
return option;
});
let text = this.$t(key);
if (appearanceSets[key] && appearanceSets[key].text) {
text = appearanceSets[key].text();
}
let compiledSet = {
key,
options,
keys,
text,
};
seasonalHairColors.push(compiledSet);
}
return seasonalHairColors;
},
premiumHairColors () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.premiumHairColorKeys;
let options = keys.map(key => {
return this.mapKeysToOption(key, 'hair', 'color');
});
return options;
},
baseHair2 () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.baseHair2Keys;
let options = keys.map(key => {
return this.mapKeysToOption(key, 'hair', 'base');
});
return options;
},
baseHair3 () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.baseHair3Keys;
let options = keys.map(key => {
const option = this.mapKeysToOption(key, 'hair', 'base');
return option;
});
return options;
},
baseHair4 () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.baseHair4Keys;
let options = keys.map(key => {
return this.mapKeysToOption(key, 'hair', 'base');
});
return options;
},
baseHair5 () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.baseHair5Keys;
let options = keys.map(key => {
return this.mapKeysToOption(key, 'hair', 'mustache');
});
return options;
},
baseHair6 () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let keys = this.baseHair6Keys;
let options = keys.map(key => {
return this.mapKeysToOption(key, 'hair', 'beard');
});
return options;
},
hairBangs () {
const none = this.mapKeysToFreeOption(0, 'hair', 'bangs');
none.none = true;
const options = [1, 2, 3, 4].map(s => this.mapKeysToFreeOption(s, 'hair', 'bangs'));
return [none, ...options];
},
mustacheList () {
const noneOption = this.mapKeysToFreeOption(0, 'hair', 'mustache');
noneOption.none = true;
return [noneOption, ...this.baseHair5];
},
beardList () {
const noneOption = this.mapKeysToFreeOption(0, 'hair', 'beard');
noneOption.none = true;
return [noneOption, ...this.baseHair6];
},
styleSets () {
const sets = [];
const emptyHairBase = {
...this.mapKeysToFreeOption(0, 'hair', 'base'),
none: true,
};
if (this.editing) {
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair3Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair3Keys.join(',hair.base.')}`),
options: [
emptyHairBase,
...this.baseHair3,
],
});
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair4Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair4Keys.join(',hair.base.')}`),
options: [
...this.baseHair4,
],
});
}
sets.push({
options: [
emptyHairBase,
...this.baseHair1.map(key => this.mapKeysToFreeOption(key, 'hair', 'base')),
],
});
if (this.editing) {
sets.push({
fullSet: !this.userOwnsSet('hair', this.baseHair2Keys, 'base'),
unlock: () => this.unlock(`hair.base.${this.baseHair2Keys.join(',hair.base.')}`),
options: [
...this.baseHair2,
],
});
}
return sets;
},
},
mounted () {
this.changeSubPage('color');
},
methods: {
/**
* Allows you to find out whether you need the "Purchase All" button or not. If there are more than 2 unpurchased items, returns true, otherwise returns false.
* @param {string} category - The selected category.
* @param {string[]} keySets - The items keySets.
* @param {string[]} [types] - The items types (subcategories). Optional.
* @returns {boolean} - Determines whether the "Purchase All" button is needed (true) or not (false).
*/
isPurchaseAllNeeded (category, keySets, types) {
const purchasedItemsLengths = [];
// If item types are specified, count them
if (types && types.length > 0) {
// Types can be undefined, so we must check them.
types.forEach((type) => {
if (this.user.purchased[category][type]) {
purchasedItemsLengths
.push(Object.keys(this.user.purchased[category][type]).length);
}
});
} else {
let purchasedItemsCounter = 0;
// If types are not specified, recursively
// search for purchased items in the category
const findPurchasedItems = (item) => {
if (typeof item === 'object') {
Object.values(item)
.forEach((innerItem) => {
if (typeof innerItem === 'boolean' && innerItem === true) {
purchasedItemsCounter += 1;
}
return findPurchasedItems(innerItem);
});
}
return purchasedItemsCounter;
};
findPurchasedItems(this.user.purchased[category]);
if (purchasedItemsCounter > 0) {
purchasedItemsLengths.push(purchasedItemsCounter);
}
}
// We don't need to count the key sets (below)
// if there are no purchased items at all.
if (purchasedItemsLengths.length === 0) {
return true;
}
const allItemsLengths = [];
// Key sets must be specify correctly.
keySets.forEach((keySet) => {
allItemsLengths.push(Object.keys(this[keySet]).length);
});
// Simply sum all the length values and
// write them into variables for the convenience.
const allItems = allItemsLengths.reduce((acc, val) => acc + val);
const purchasedItems = purchasedItemsLengths.reduce((acc, val) => acc + val);
const unpurchasedItems = allItems - purchasedItems;
return unpurchasedItems > 2;
},
},
};
</script>
<style scoped>
</style>
@@ -0,0 +1,114 @@
<template lang="pug">
#skin.section.customize-section
sub-menu.text-center(:items="skinSubMenuItems", :activeSubPage="activeSubPage", @changeSubPage="changeSubPage($event)")
customize-options(
:items="freeSkins",
:currentValue="user.preferences.skin"
)
div(v-if='editing && set.key !== "undefined"', v-for='set in seasonalSkins')
customize-options(
:items='set.options',
:currentValue="user.preferences.skin",
:fullSet='!hideSet(set) && !userOwnsSet("skin", set.keys)',
@unlock='unlock(`skin.${set.keys.join(",skin.")}`)'
)
</template>
<script>
import appearance from 'common/script/content/appearance';
import {subPageMixin} from '../../mixins/subPage';
import {userStateMixin} from '../../mixins/userState';
import {avatarEditorUtilies} from '../../mixins/avatarEditUtilities';
import appearanceSets from 'common/script/content/appearance/sets';
import subMenu from './sub-menu';
import customizeOptions from './customize-options';
import gem from 'assets/svg/gem.svg';
import groupBy from 'lodash/groupBy';
const skinsBySet = groupBy(appearance.skin, 'set.key');
const freeSkinKeys = skinsBySet[undefined].map(s => s.key);
// const specialSkinKeys = Object.keys(appearance.shirt).filter(k => appearance.shirt[k].price !== 0);
export default {
props: [
'editing',
],
components: {
subMenu,
customizeOptions,
},
mixins: [
subPageMixin,
userStateMixin,
avatarEditorUtilies,
],
data () {
return {
freeSkinKeys,
icons: Object.freeze({
gem,
}),
skinSubMenuItems: [
{
id: 'color',
label: this.$t('color'),
},
],
};
},
computed: {
freeSkins () {
return freeSkinKeys.map(s => this.mapKeysToFreeOption(s, 'skin'));
},
seasonalSkins () {
// @TODO: For some resonse when I use $set on the user purchases object, this is not recomputed. Hack for now
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
let seasonalSkins = [];
for (let setKey in skinsBySet) {
let set = skinsBySet[setKey];
let keys = set.map(item => {
return item.key;
});
let options = keys.map(optionKey => {
const option = this.mapKeysToOption(optionKey, 'skin', '', setKey);
return option;
});
let text = this.$t(setKey);
if (appearanceSets[setKey] && appearanceSets[setKey].text) {
text = appearanceSets[setKey].text();
}
let compiledSet = {
key: setKey,
options,
keys,
text,
};
seasonalSkins.push(compiledSet);
}
return seasonalSkins;
},
},
mounted () {
this.changeSubPage('color');
},
methods: {
},
};
</script>
<style scoped>
</style>
@@ -0,0 +1,52 @@
<template lang="pug">
.sub-menu.text-center
.sub-menu-item(
v-for="item of items",
:key="item.id",
@click='$emit("changeSubPage", item.id)',
:class='{active: activeSubPage === item.id}'
)
strong(v-once) {{ item.label }}
</template>
<script>
export default {
props: ['items', 'activeSubPage'],
};
</script>
<style scoped lang="scss">
@import '~client/assets/scss/colors.scss';
.sub-menu {
display: flex;
justify-content: center;
margin-bottom: 10px;
padding-top: 12px;
flex-wrap: wrap;
}
.sub-menu:hover {
cursor: pointer;
}
.sub-menu-item {
padding: 6px 16px;
text-align: center;
border-bottom: 2px solid #f9f9f9;
height: 32px;
font-size: 12px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.67;
letter-spacing: normal;
text-align: center;
color: $gray-100;
}
.sub-menu .sub-menu-item:hover, .sub-menu .sub-menu-item.active {
color: $purple-200;
border-bottom: 2px solid $purple-200;
}
</style>
@@ -0,0 +1,42 @@
<template lang="pug">
b-modal#banned-account(:title="$t('accountSuspendedTitle')", size='md', :hide-footer="true")
.modal-body
.row
.col-12
p(v-markdown='bannedMessage')
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{$t('close')}}
</template>
<style scoped>
</style>
<script>
import markdownDirective from 'client/directives/markdown';
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS.COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
export default {
directives: {
markdown: markdownDirective,
},
computed: {
bannedMessage () {
const AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings');
const parseSettings = JSON.parse(AUTH_SETTINGS);
const userId = parseSettings ? parseSettings.auth.apiId : '';
return this.$t('accountSuspended', {
userId,
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
});
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'banned-account');
},
},
};
</script>
@@ -0,0 +1,34 @@
<template lang="pug">
.categories
span.category-label.category-label-blue(v-if='owner')
| {{ $t('owned') }}
span.category-label.category-label-green(v-if='member')
| {{ $t('joined') }}
span.category-label(
v-for='category in categories',
:class="{'category-label-purple':isOfficial(category)}"
)
| {{ $t(category.name) }}
slot
</template>
<script>
export default {
props: {
categories: {
required: true,
},
owner: {
default: false,
},
member: {
default: false,
},
},
methods: {
isOfficial (category) {
return category.name === 'habitica_official';
},
},
};
</script>
@@ -0,0 +1,401 @@
<template lang="pug">
.row
challenge-modal(@updatedChallenge='updatedChallenge')
leave-challenge-modal(:challengeId='challenge._id')
close-challenge-modal(:members='members', :challengeId='challenge._id', :prize='challenge.prize')
challenge-member-progress-modal(:challengeId='challenge._id')
.col-12.col-md-8.standard-page
.row
.col-12.col-md-6
h1(v-markdown='challenge.name')
div
span.mr-1.ml-0.d-block
strong(v-once) {{ $t('createdBy') }}:
user-link.mx-1(:user="challenge.leader")
span.mr-1.ml-0.d-block(v-if="challenge.group && challenge.group.name !== 'Tavern'")
strong(v-once) {{ $t(challenge.group.type) }}:
group-link.mx-1(:group="challenge.group")
// @TODO: make challenge.author a variable inside the createdBy string (helps with RTL languages)
// @TODO: Implement in V2 strong.margin-left(v-once)
.svg-icon.calendar-icon(v-html="icons.calendarIcon")
| {{$t('endDate')}}
// "endDate": "End Date: <% endDate %>",
// span {{challenge.endDate}}
.tags
span.tag(v-for='tag in challenge.tags') {{tag}}
.col-12.col-md-6.text-right
.box(@click="showMemberModal()")
.svg-icon.member-icon(v-html="icons.memberIcon")
| {{challenge.memberCount}}
.details(v-once) {{$t('participantsTitle')}}
.box
.svg-icon.gem-icon(v-html="icons.gemIcon")
| {{challenge.prize}}
.details(v-once) {{$t('prize')}}
.row.challenge-actions
.col-12.col-md-6
strong.view-progress {{ $t('viewProgressOf') }}
member-search-dropdown(:text="$t('selectParticipant')", :members='members', :challengeId='challengeId', @member-selected='openMemberProgressModal')
.col-12.col-md-6.text-right
span(v-if='isLeader || isAdmin')
b-dropdown.create-dropdown(:text="$t('addTaskToChallenge')", :variant="'success'")
b-dropdown-item(v-for="type in columns", :key="type", @click="createTask(type)")
| {{$t(type)}}
task-modal(
:task="workingTask",
:purpose="taskFormPurpose",
@cancel="cancelTaskModal()",
ref="taskModal",
:challengeId="challengeId",
@taskCreated='taskCreated',
@taskEdited='taskEdited',
@taskDestroyed='taskDestroyed'
)
.row
task-column.col-12.col-sm-6(
v-for="column in columns",
:type="column",
:key="column",
:taskListOverride='tasksByType[column]',
:showOptions="showOptions",
@editTask="editTask",
@taskDestroyed="taskDestroyed",
v-if='tasksByType[column].length > 0')
.col-12.col-md-4.sidebar.standard-page
.button-container(v-if='canJoin')
button.btn.btn-success(v-once, @click='joinChallenge()') {{$t('joinChallenge')}}
.button-container(v-if='isLeader || isAdmin')
button.btn.btn-primary(v-once, @click='edit()') {{$t('editChallenge')}}
.button-container(v-if='isLeader || isAdmin')
button.btn.btn-primary(v-once, @click='cloneChallenge()') {{$t('clone')}}
.button-container(v-if='isLeader || isAdmin')
button.btn.btn-primary(v-once, @click='exportChallengeCsv()') {{$t('exportChallengeCsv')}}
.button-container(v-if='isLeader || isAdmin')
button.btn.btn-danger(v-once, @click='closeChallenge()') {{$t('endChallenge')}}
div
sidebar-section(:title="$t('challengeSummary')")
p(v-markdown='challenge.summary')
sidebar-section(:title="$t('challengeDescription')")
p(v-markdown='challenge.description')
.text-center(v-if='isMember')
button.btn.btn-danger(v-once, @click='leaveChallenge()') {{$t('leaveChallenge')}}
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
h1 {
color: $purple-200;
margin-bottom: 8px;
}
.margin-left {
margin-left: 1em;
}
span {
margin-left: .5em;
}
.button-container {
margin-bottom: 1em;
button {
width: 100%;
}
}
.calendar-icon {
width: 12px;
display: inline-block;
margin-right: .2em;
}
.tags {
margin-top: 1em;
}
.tag {
border-radius: 30px;
background-color: $gray-600;
padding: .5em;
}
.sidebar {
background-color: $gray-600;
}
.box {
display: inline-block;
padding: 1em;
border-radius: 2px;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
margin-left: 1em;
width: 120px;
height: 76px;
text-align: center;
font-size: 20px;
vertical-align: bottom;
.svg-icon {
width: 30px;
display: inline-block;
margin-right: .2em;
vertical-align: bottom;
}
.details {
font-size: 12px;
margin-top: 0.4em;
color: $gray-200;
}
}
.description-section {
margin-top: 2em;
}
.challenge-actions {
margin-top: 1em;
margin-bottom: 2em;
.view-progress {
margin-right: .5em;
}
}
</style>
<script>
const TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt', 'challenge'];
import Vue from 'vue';
import findIndex from 'lodash/findIndex';
import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import uuid from 'uuid';
import { mapState } from 'client/libs/store';
import memberSearchDropdown from 'client/components/members/memberSearchDropdown';
import closeChallengeModal from './closeChallengeModal';
import Column from '../tasks/column';
import TaskModal from '../tasks/taskModal';
import markdownDirective from 'client/directives/markdown';
import challengeModal from './challengeModal';
import challengeMemberProgressModal from './challengeMemberProgressModal';
import challengeMemberSearchMixin from 'client/mixins/challengeMemberSearch';
import leaveChallengeModal from './leaveChallengeModal';
import sidebarSection from '../sidebarSection';
import userLink from '../userLink';
import groupLink from '../groupLink';
import taskDefaults from 'common/script/libs/taskDefaults';
import gemIcon from 'assets/svg/gem.svg';
import memberIcon from 'assets/svg/member-icon.svg';
import calendarIcon from 'assets/svg/calendar.svg';
export default {
props: ['challengeId'],
mixins: [challengeMemberSearchMixin],
directives: {
markdown: markdownDirective,
},
components: {
closeChallengeModal,
leaveChallengeModal,
challengeModal,
challengeMemberProgressModal,
memberSearchDropdown,
sidebarSection,
TaskColumn: Column,
TaskModal,
userLink,
groupLink,
},
data () {
return {
searchId: '',
columns: ['habit', 'daily', 'todo', 'reward'],
icons: Object.freeze({
gemIcon,
memberIcon,
calendarIcon,
}),
challenge: {},
members: [],
tasksByType: {
habit: [],
daily: [],
todo: [],
reward: [],
},
editingTask: {},
creatingTask: {},
workingTask: {},
taskFormPurpose: 'create',
searchTerm: '',
memberResults: [],
};
},
computed: {
...mapState({user: 'user.data'}),
isMember () {
return this.user.challenges.indexOf(this.challenge._id) !== -1;
},
isLeader () {
if (!this.challenge.leader) return false;
return this.user._id === this.challenge.leader._id;
},
isAdmin () {
return Boolean(this.user.contributor.admin);
},
canJoin () {
return !this.isMember;
},
showOptions () {
return this.isLeader;
},
},
mounted () {
if (!this.searchId) this.searchId = this.challengeId;
if (!this.challenge._id) this.loadChallenge();
},
async beforeRouteUpdate (to, from, next) {
this.searchId = to.params.challengeId;
await this.loadChallenge();
next();
},
methods: {
cleanUpTask (task) {
let cleansedTask = omit(task, TASK_KEYS_TO_REMOVE);
// Copy checklists but reset to uncomplete and assign new id
if (!cleansedTask.checklist) cleansedTask.checklist = [];
cleansedTask.checklist.forEach((item) => {
item.completed = false;
item.id = uuid();
});
if (cleansedTask.type !== 'reward') {
delete cleansedTask.value;
}
return cleansedTask;
},
async loadChallenge () {
this.challenge = await this.$store.dispatch('challenges:getChallenge', {challengeId: this.searchId});
this.members = await this.loadMembers({ challengeId: this.searchId, includeAllPublicFields: true });
let tasks = await this.$store.dispatch('tasks:getChallengeTasks', {challengeId: this.searchId});
this.tasksByType = {
habit: [],
daily: [],
todo: [],
reward: [],
};
tasks.forEach((task) => {
this.tasksByType[task.type].push(task);
});
},
/**
* Method for loading members of a group, with optional parameters for
* modifying requests.
*
* @param {Object} payload Used for modifying requests for members
*/
loadMembers (payload = null) {
// Remove unnecessary data
if (payload && payload.groupId) {
delete payload.groupId;
}
return this.$store.dispatch('members:getChallengeMembers', payload);
},
editTask (task) {
this.taskFormPurpose = 'edit';
this.editingTask = cloneDeep(task);
this.workingTask = this.editingTask;
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
createTask (type) {
this.taskFormPurpose = 'create';
this.creatingTask = taskDefaults({type, text: ''}, this.user);
this.workingTask = this.creatingTask;
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
cancelTaskModal () {
this.editingTask = null;
this.creatingTask = null;
},
taskCreated (task) {
this.tasksByType[task.type].push(task);
},
taskEdited (task) {
let index = findIndex(this.tasksByType[task.type], (taskItem) => {
return taskItem._id === task._id;
});
this.tasksByType[task.type].splice(index, 1, task);
},
taskDestroyed (task) {
let index = findIndex(this.tasksByType[task.type], (taskItem) => {
return taskItem._id === task._id;
});
this.tasksByType[task.type].splice(index, 1);
},
showMemberModal () {
this.$root.$emit('habitica:show-member-modal', {
challengeId: this.challenge._id,
groupId: 'challenge', // @TODO: change these terrible settings
group: this.challenge.group,
memberCount: this.challenge.memberCount,
viewingMembers: this.members,
fetchMoreMembers: this.loadMembers,
});
},
async joinChallenge () {
this.user.challenges.push(this.searchId);
await this.$store.dispatch('challenges:joinChallenge', {challengeId: this.searchId});
await this.$store.dispatch('tasks:fetchUserTasks', {forceLoad: true});
},
async leaveChallenge () {
this.$root.$emit('bv::show::modal', 'leave-challenge-modal');
},
closeChallenge () {
this.$root.$emit('bv::show::modal', 'close-challenge-modal');
},
edit () {
this.$root.$emit('habitica:update-challenge', {
challenge: this.challenge,
});
},
// @TODO: view members
updatedChallenge (eventData) {
Object.assign(this.challenge, eventData.challenge);
},
openMemberProgressModal (member) {
this.$root.$emit('habitica:challenge:member-progress', {
progressMemberId: member._id,
isLeader: this.isLeader,
isAdmin: this.isAdmin,
});
},
async exportChallengeCsv () {
// let response = await this.$store.dispatch('challenges:exportChallengeCsv', {
// challengeId: this.searchId,
// });
window.location = `/api/v4/challenges/${this.searchId}/export/csv`;
},
cloneChallenge () {
this.$root.$emit('habitica:clone-challenge', {
challenge: this.challenge,
});
},
},
};
</script>
@@ -0,0 +1,391 @@
<template lang="pug">
.challenge
.challenge-prize
.number
span.svg-icon(v-html="icons.gemIcon")
span.value {{challenge.prize}}
.label {{ $t('prize') }}
.challenge-header
router-link(
:to="{ name: 'challenge', params: { challengeId: challenge._id } }"
)
h3.challenge-title(v-markdown='challenge.name')
.meta-info
.member-count
.svg-icon.user-icon(v-html="icons.memberIcon")
span.count-label {{challenge.memberCount}}
.divider
.official(v-if="isOfficial")
.svg-icon.user-icon(v-html="icons.officialIcon")
.owner(v-if="fullLayout")
.owner-item
strong {{ $t('createdBy') }}:
user-link.mx-1(:user="challenge.leader")
.owner-item(v-if="challenge.group && !isTavern(challenge.group)")
strong {{ $t(challenge.group.type) }}:
group-link.mx-1(:group="challenge.group")
category-tags.challenge-categories(
:categories="challenge.categories",
:owner="isOwner",
:member="isMember",
)
.challenge-description(v-markdown='challenge.summary')
.well-wrapper(v-if="fullLayout")
.well
div(v-for="task in tasksData", :class="{'muted': task.value === 0}")
.number
.svg-icon(v-html="task.icon", :class="task.label + '-icon'")
span.value {{ task.value }}
.label {{$t(task.label)}}
</template>
<style lang="scss">
@import '~client/assets/scss/colors.scss';
// Have to use this, because v-markdown creates p element in h3. Scoping doesn't work with it.
.challenge-title > p {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
font-size: 20px;
font-weight: bold;
font-style: normal;
font-stretch: condensed;
line-height: 1.4;
letter-spacing: normal;
color: $gray-10;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.challenge {
background-color: $white;
box-shadow: 0 2px 2px 0 rgba($black, 0.15), 0 1px 4px 0 rgba($black, 0.1);
margin-bottom: 1em;
border-radius: 4px;
padding-bottom: .5em;
.number {
display: flex;
align-items: center;
justify-content: center;
.svg-icon {
margin-top: -.2em;
}
.value {
margin-left: .5em;
font-size: 24px;
}
}
.label {
font-size: .9em;
}
.official {
margin-right: 8px;
}
.challenge-prize {
text-align: center;
color: $gems-color;
display: inline-block;
float: right;
padding: 18px 24px;
margin-left: 1em;
background: #24cc8f19;
border-bottom-left-radius: 4px;
width: 107px;
height: 80px;
.svg-icon {
width: 24px;
height: 24px;
object-fit: contain;
}
.value {
margin-left: 8px;
font-size: 20px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.4;
letter-spacing: normal;
color: #1ca372;
}
.label {
margin-top: 4px;
font-size: 12px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
text-align: center;
color: #1ca372;
}
}
.divider {
width: 1px;
height: 16px;
background-color: $gray-600;
margin-right: 12px;
margin-left: 12px;
}
.challenge-header {
padding: 16px 20px;
}
.well-wrapper {
padding: 16px 20px 20px;
}
.challenge-header {
padding-bottom: 0;
}
.meta-info {
height: 24px;
margin-bottom: 8px;
}
.count-label {
font-size: 14px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.71;
letter-spacing: normal;
color: $gray-50;
margin-left: 4px;
}
.meta-info, .owner, .member-count {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
}
.meta-item, .owner-item {
display: inline-flex;
color: $gray-200;
align-items: center;
margin-right: 1em;
white-space: nowrap;
.svg-icon {
color: $gray-300;
width: 14px;
}
}
.challenge-categories {
clear: right;
display: flex;
padding: 0 20px 16px;
flex-wrap: wrap;
}
.user-icon {
width: 20px !important;
}
.habit-icon {
width: 30px;
}
.todo-icon {
width: 20px;
}
.daily-icon {
width: 24px;
}
.reward-icon {
width: 26px;
}
.challenge-description {
margin: 0 20px 0;
word-break: break-word;
font-size: 14px;
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.71;
letter-spacing: normal;
color: $gray-50;
}
.well {
display: flex;
align-items: center;
justify-content: space-evenly;
background-color: $gray-700;
text-align: center;
padding: 16px;
border-radius: .25em;
> div {
.value {
font-size: 20px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.4;
letter-spacing: normal;
color: $gray-50;
}
.label {
font-size: 12px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
text-align: center;
color: $gray-100;
}
.svg-icon {
object-fit: contain;
color: $gray-100;
}
}
> div.muted {
.value {
opacity: 0.5;
font-size: 20px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.4;
letter-spacing: normal;
color: $gray-50;
}
.label {
opacity: 0.5;
font-size: 12px;
font-weight: bold;
font-style: normal;
font-stretch: normal;
line-height: 1.33;
letter-spacing: normal;
text-align: center;
color: $gray-100;
}
.svg-icon {
object-fit: contain;
opacity: 0.5;
color: $gray-100;
}
}
}
}
</style>
<script>
import { TAVERN_ID } from '../../../common/script/constants';
import userLink from '../userLink';
import groupLink from '../groupLink';
import categoryTags from '../categories/categoryTags';
import markdownDirective from 'client/directives/markdown';
import {mapState} from 'client/libs/store';
import gemIcon from 'assets/svg/gem.svg';
import memberIcon from 'assets/svg/member-icon.svg';
import calendarIcon from 'assets/svg/calendar.svg';
import habitIcon from 'assets/svg/habit.svg';
import todoIcon from 'assets/svg/todo.svg';
import dailyIcon from 'assets/svg/daily.svg';
import rewardIcon from 'assets/svg/reward.svg';
import officialIcon from 'assets/svg/official.svg';
export default {
props: {
challenge: {
required: true,
},
fullLayout: {
default: true,
},
},
components: {
userLink,
groupLink,
categoryTags,
},
directives: {
markdown: markdownDirective,
},
data () {
return {
icons: Object.freeze({
gemIcon,
memberIcon,
calendarIcon,
officialIcon,
}),
};
},
computed: {
...mapState({user: 'user.data'}),
isOwner () {
return this.challenge.leader && this.challenge.leader._id === this.user._id;
},
isMember () {
return this.user.challenges.indexOf(this.challenge._id) !== -1;
},
isOfficial () {
return this.challenge.official || this.challenge.categories.map(category => category.slug).includes('habitica_official');
},
tasksData () {
return [
{
icon: habitIcon,
label: 'habit',
value: this.challenge.tasksOrder.habits.length,
},
{
icon: dailyIcon,
label: 'daily',
value: this.challenge.tasksOrder.dailys.length,
},
{
icon: todoIcon,
label: 'todo',
value: this.challenge.tasksOrder.todos.length,
},
{
icon: rewardIcon,
label: 'reward',
value: this.challenge.tasksOrder.rewards.length,
},
];
},
},
methods: {
isTavern (group) {
return group._id === TAVERN_ID;
},
},
};
</script>
@@ -0,0 +1,83 @@
<template lang="pug">
b-modal#challenge-member-modal(title="User Progress", size='lg')
.row.award-row(v-if='isLeader || isAdmin')
.col-12.text-center
button.btn.btn-primary(v-once, @click='closeChallenge()') {{ $t('awardWinners') }}
.row
task-column.col-6(
v-for="column in columns",
:type="column",
:key="column",
:taskListOverride='tasksByType[column]')
</template>
<style scoped>
.award-row {
padding-top: 1rem;
padding-bottom: 1rem;
}
</style>
<script>
import axios from 'axios';
import Column from '../tasks/column';
export default {
props: ['challengeId'],
components: {
TaskColumn: Column,
},
data () {
return {
columns: ['habit', 'daily', 'todo', 'reward'],
tasksByType: {
habit: [],
daily: [],
todo: [],
reward: [],
},
memberId: '',
isLeader: false,
isAdmin: false,
};
},
mounted () {
this.$root.$on('habitica:challenge:member-progress', (data) => {
if (!data.progressMemberId) return;
this.memberId = data.progressMemberId;
this.isLeader = data.isLeader;
this.isAdmin = data.isAdmin;
this.$root.$emit('bv::show::modal', 'challenge-member-modal');
});
},
beforeDestroy () {
this.$root.$off('habitica:challenge:member-progress');
},
watch: {
async memberId (id) {
if (!id) return;
this.tasksByType = {
habit: [],
daily: [],
todo: [],
reward: [],
};
let response = await axios.get(`/api/v4/challenges/${this.challengeId}/members/${this.memberId}`);
let tasks = response.data.data.tasks;
tasks.forEach((task) => {
this.tasksByType[task.type].push(task);
});
},
},
methods: {
async closeChallenge () {
this.challenge = await this.$store.dispatch('challenges:selectChallengeWinner', {
challengeId: this.challengeId,
winnerId: this.memberId,
});
this.$router.push('/challenges/myChallenges');
},
},
};
</script>
@@ -0,0 +1,478 @@
<template lang="pug">
b-modal#challenge-modal(:title="title", size='lg', @shown="shown")
.form
.form-group
label
strong(v-once) {{$t('name')}} *
b-form-input(type="text", :placeholder="$t('challengeNamePlaceholder')", v-model="workingChallenge.name")
.form-group
label
strong(v-once) {{$t('shortName')}} *
b-form-input(type="text", :placeholder="$t('shortNamePlaceholder')", v-model="workingChallenge.shortName")
.form-group
label
strong(v-once) {{$t('challengeSummary')}} *
div.summary-count {{ $t('charactersRemaining', {characters: charactersRemaining}) }}
textarea.summary-textarea.form-control(:placeholder="$t('challengeSummaryPlaceholder')", v-model="workingChallenge.summary")
.form-group
label
strong(v-once) {{$t('challengeDescription')}} *
a.float-right(v-markdown='$t("markdownFormattingHelp")')
textarea.description-textarea.form-control(:placeholder="$t('challengeDescriptionPlaceholder')", v-model="workingChallenge.description")
.form-group(v-if='creating')
label
strong(v-once) {{$t('challengeGuild')}} *
select.form-control(v-model='workingChallenge.group')
option(v-for='group in groups', :value='group._id') {{group.name}}
.form-group(v-if='workingChallenge.categories')
label
strong(v-once) {{$t('categories')}} *
div.category-wrap(@click.prevent="toggleCategorySelect")
span.category-select(v-if='workingChallenge.categories.length === 0') {{$t('none')}}
.category-label(v-for='category in workingChallenge.categories') {{$t(categoriesHashByKey[category])}}
.category-box(v-if="showCategorySelect && creating")
.form-check(
v-for="group in categoryOptions",
:key="group.key",
v-if='group.key !== "habitica_official" || user.contributor.admin'
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox",
:value="group.key",
:id="`challenge-modal-cat-${group.key}`",
v-model="workingChallenge.categories")
label.custom-control-label(v-once, :for="`challenge-modal-cat-${group.key}`") {{ $t(group.label) }}
button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}}
// @TODO: Implement in V2 .form-group
label
strong(v-once) {{$t('endDate')}}
b-form-input.end-date-input
.form-group(v-if='creating')
label
strong(v-once) {{$t('prize')}}
input(type='number', :min='minPrize', :max='maxPrize', v-model="workingChallenge.prize")
.row.footer-wrap
.col-12.text-center.submit-button-wrapper
.alert.alert-warning(v-if='insufficientGemsForTavernChallenge') You do not have enough gems to create a Tavern challenge
// @TODO if buy gems button is added, add analytics tracking to it
// see https://github.com/HabitRPG/habitica/blob/develop/website/views/options/social/challenges.jade#L134
button.btn.btn-primary(v-if='creating && !cloning', @click='createChallenge()', :disabled='loading') {{$t('createChallengeAddTasks')}}
button.btn.btn-primary(v-once, v-if='cloning', @click='createChallenge()', :disabled='loading') {{$t('createChallengeCloneTasks')}}
button.btn.btn-primary(v-once, v-if='!creating && !cloning', @click='updateChallenge()') {{$t('updateChallenge')}}
.col-12.text-center
p(v-once) {{$t('challengeMinimum')}}
</template>
<style lang='scss'>
@import '~client/assets/scss/colors.scss';
#challenge-modal {
h5 {
color: $purple-200;
margin-bottom: 1.5em;
}
.modal-header {
border: none;
}
.modal-footer {
display: none;
}
.summary-count {
font-size: 12px;
line-height: 1.33;
margin-top: 1em;
color: $gray-200;
text-align: right;
}
.summary-textarea {
height: 90px;
}
.description-textarea {
height: 220px;
}
label {
margin-right: .5em;
}
.end-date-input {
width: 150px;
display: inline-block;
}
.form-group {
margin-bottom: 2em;
}
.submit-button-wrapper {
margin-bottom: .5em;
}
.footer-wrap {
margin-top: 2em;
margin-bottom: 2em;
}
.category-wrap {
position: relative;
}
.category-box {
top: 20em !important;
z-index: 10;
}
}
</style>
<script>
import clone from 'lodash/clone';
import markdownDirective from 'client/directives/markdown';
import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHALLENGES } from '../../../common/script/constants';
import { mapState } from 'client/libs/store';
export default {
props: ['groupId'],
directives: {
markdown: markdownDirective,
},
data () {
let categoryOptions = [
{
label: 'habitica_official',
key: 'habitica_official',
},
{
label: 'academics',
key: 'academics',
},
{
label: 'advocacy_causes',
key: 'advocacy_causes',
},
{
label: 'creativity',
key: 'creativity',
},
{
label: 'entertainment',
key: 'entertainment',
},
{
label: 'finance',
key: 'finance',
},
{
label: 'health_fitness',
key: 'health_fitness',
},
{
label: 'hobbies_occupations',
key: 'hobbies_occupations',
},
{
label: 'location_based',
key: 'location_based',
},
{
label: 'mental_health',
key: 'mental_health',
},
{
label: 'getting_organized',
key: 'getting_organized',
},
{
label: 'self_improvement',
key: 'self_improvement',
},
{
label: 'spirituality',
key: 'spirituality',
},
{
label: 'time_management',
key: 'time_management',
},
];
let hashedCategories = {};
categoryOptions.forEach((category) => {
hashedCategories[category.key] = category.label;
});
let categoriesHashByKey = hashedCategories;
return {
workingChallenge: {
name: '',
summary: '',
description: '',
categories: [],
group: '',
dailys: [],
habits: [],
leader: '',
members: [],
official: false,
prize: 1,
rewards: [],
shortName: '',
todos: [],
},
cloning: false,
cloningChallengeId: '',
showCategorySelect: false,
categoryOptions,
categoriesHashByKey,
loading: false,
groups: [],
};
},
mounted () {
this.$root.$on('habitica:clone-challenge', (data) => {
if (!data.challenge) return;
this.cloning = true;
this.cloningChallengeId = data.challenge._id;
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, data.challenge);
this.$root.$emit('bv::show::modal', 'challenge-modal');
});
this.$root.$on('habitica:update-challenge', (data) => {
if (!data.challenge) return;
this.cloning = false;
this.$store.state.challengeOptions.workingChallenge = Object.assign({}, this.$store.state.challengeOptions.workingChallenge, data.challenge);
this.$root.$emit('bv::show::modal', 'challenge-modal');
});
},
beforeDestroy () {
this.$root.$off('habitica:clone-challenge');
},
watch: {
user () {
if (!this.challenge) this.workingChallenge.leader = this.user._id;
},
challenge () {
this.setUpWorkingChallenge();
},
cloning () {
this.setUpWorkingChallenge();
},
},
computed: {
...mapState({user: 'user.data'}),
creating () {
return !this.workingChallenge.id;
},
title () {
if (this.creating) {
return this.$t('createChallenge');
}
return this.$t('editingChallenge');
},
charactersRemaining () {
let currentLength = this.workingChallenge.summary ? this.workingChallenge.summary.length : 0;
return MAX_SUMMARY_SIZE_FOR_CHALLENGES - currentLength;
},
maxPrize () {
let userBalance = this.user.balance || 0;
userBalance = userBalance * 4;
let groupBalance = 0;
let group;
this.groups.forEach(item => {
if (item._id === this.workingChallenge.group) {
group = item;
return;
}
});
if (group && group.balance && group.leader === this.user._id) {
groupBalance = group.balance * 4;
}
return userBalance + groupBalance;
},
minPrize () {
if (this.workingChallenge.group === TAVERN_ID) return 1;
return 0;
},
insufficientGemsForTavernChallenge () {
let balance = this.user.balance || 0;
let isForTavern = this.workingChallenge.group === TAVERN_ID;
if (isForTavern) {
return balance <= 0;
} else {
return false;
}
},
challenge () {
return this.$store.state.challengeOptions.workingChallenge;
},
},
methods: {
async shown () {
this.groups = await this.$store.dispatch('guilds:getMyGuilds');
if (this.user.party && this.user.party._id) {
await this.$store.dispatch('party:getParty');
const party = this.$store.state.party.data;
if (party._id) {
this.groups.push({
name: party.name,
_id: party._id,
privacy: 'private',
});
}
}
this.groups.push({
name: this.$t('publicChallengesTitle'),
_id: TAVERN_ID,
});
this.setUpWorkingChallenge();
},
setUpWorkingChallenge () {
this.resetWorkingChallenge();
if (!this.challenge) return;
this.workingChallenge = Object.assign({}, this.workingChallenge, this.challenge);
// @TODO: Should we use a separate field? I think the API expects `group` but it is confusing
this.workingChallenge.group = this.workingChallenge.group._id;
this.workingChallenge.categories = [];
if (this.challenge.categories) {
this.challenge.categories.forEach(category => {
this.workingChallenge.categories.push(category.slug);
});
}
if (this.cloning) {
this.$delete(this.workingChallenge, '_id');
this.$delete(this.workingChallenge, 'id');
}
},
resetWorkingChallenge () {
this.workingChallenge = {
name: '',
summary: '',
description: '',
categories: [],
group: '',
dailys: [],
habits: [],
leader: '',
members: [],
official: false,
prize: 1,
rewards: [],
shortName: '',
todos: [],
};
this.$store.state.workingChallenge = {};
},
async createChallenge () {
this.loading = true;
// @TODO: improve error handling, add it to updateChallenge, make errors translatable. Suggestion: `<% fieldName %> is required` where possible, where `fieldName` is inserted as the translatable string that's used for the field header.
let errors = [];
if (!this.workingChallenge.name) errors.push(this.$t('nameRequired'));
if (this.workingChallenge.shortName.length < MIN_SHORTNAME_SIZE_FOR_CHALLENGES) errors.push(this.$t('tagTooShort'));
if (!this.workingChallenge.summary) errors.push(this.$t('summaryRequired'));
if (this.workingChallenge.summary.length > MAX_SUMMARY_SIZE_FOR_CHALLENGES) errors.push(this.$t('summaryTooLong'));
if (!this.workingChallenge.description) errors.push(this.$t('descriptionRequired'));
if (!this.workingChallenge.group) errors.push(this.$t('locationRequired'));
if (!this.workingChallenge.categories || this.workingChallenge.categories.length === 0) errors.push(this.$t('categoiresRequired'));
if (errors.length > 0) {
alert(errors.join('\n'));
this.loading = false;
return;
}
this.workingChallenge.timestamp = new Date().getTime();
let categoryKeys = this.workingChallenge.categories;
let serverCategories = [];
categoryKeys.forEach(key => {
let catName = this.categoriesHashByKey[key];
serverCategories.push({
slug: key,
name: catName,
});
});
let challengeDetails = clone(this.workingChallenge);
challengeDetails.categories = serverCategories;
let challenge;
if (this.cloning) {
challenge = await this.$store.dispatch('challenges:cloneChallenge', {
challenge: challengeDetails,
cloningChallengeId: this.cloningChallengeId,
});
this.cloningChallengeId = '';
} else {
challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: challengeDetails});
}
// Update Group Prize
let challengeGroup = this.groups.find(group => {
return group._id === this.workingChallenge.group;
});
// @TODO: Share with server
const prizeCost = this.workingChallenge.prize / 4;
const challengeGroupLeader = challengeGroup.leader && challengeGroup.leader._id ? challengeGroup.leader._id : challengeGroup.leader;
const userIsLeader = challengeGroupLeader === this.user._id;
if (challengeGroup && userIsLeader && challengeGroup.balance > 0 && challengeGroup.balance >= prizeCost) {
// Group pays for all of prize
} else if (challengeGroup && userIsLeader && challengeGroup.balance > 0) {
// User pays remainder of prize cost after group
let remainder = prizeCost - challengeGroup.balance;
this.user.balance -= remainder;
} else {
// User pays for all of prize
this.user.balance -= prizeCost;
}
this.$emit('createChallenge', challenge);
this.resetWorkingChallenge();
this.$root.$emit('bv::hide::modal', 'challenge-modal');
this.$router.push(`/challenges/${challenge._id}`);
},
updateChallenge () {
let categoryKeys = this.workingChallenge.categories;
let serverCategories = [];
categoryKeys.forEach(key => {
let newKey = key.trim();
let catName = this.categoriesHashByKey[newKey];
serverCategories.push({
slug: newKey,
name: catName,
});
});
let challengeDetails = clone(this.workingChallenge);
challengeDetails.categories = serverCategories;
this.$emit('updatedChallenge', {
challenge: challengeDetails,
});
this.$store.dispatch('challenges:updateChallenge', {challenge: challengeDetails});
this.resetWorkingChallenge();
this.$root.$emit('bv::hide::modal', 'challenge-modal');
},
toggleCategorySelect () {
this.showCategorySelect = !this.showCategorySelect;
},
},
};
</script>
@@ -0,0 +1,113 @@
<template lang="pug">
div
b-modal#close-challenge-modal(:title="$t('createGuild')", size='md')
.header-wrap(slot="modal-header")
h2.text-center(v-once) {{$t('endChallenge')}}
.row.text-center
.col-12
.support-habitica
// @TODO: Add challenge achievement badge here
.col-12
strong(v-once) {{$t('selectChallengeWinnersDescription')}}
.col-12
member-search-dropdown(:text='winnerText', :members='members', :challengeId='challengeId', @member-selected='selectMember')
.col-12
button.btn.btn-primary(v-once, @click='closeChallenge') {{$t('awardWinners')}}
.col-12
hr
.or {{$t('or')}}
.col-12
strong(v-once) {{$t('doYouWantedToDeleteChallenge')}}
.col-12
button.btn.btn-danger(v-once, @click='deleteChallenge()') {{$t('deleteChallenge')}}
.footer-wrap(slot="modal-footer")
</template>
<style lang='scss'>
@import '~client/assets/scss/colors.scss';
#close-challenge-modal {
h2 {
color: $purple-200
}
#close-challenge-modal_modal_body {
padding-bottom: 2em;
}
.header-wrap {
width: 100%;
padding-top: 2em;
}
.support-habitica {
background-image: url('~client/assets/svg/for-css/support-habitica-gems.svg');
width: 325px;
height: 89px;
margin: 0 auto;
}
.modal-footer, .modal-header {
border: none !important;
}
.footer-wrap {
display: none;
}
.col-12 {
margin-top: 2em;
}
.or {
margin-top: -2em;
background: $white;
width: 40px;
margin-right: auto;
margin-left: auto;
font-weight: bold;
}
}
</style>
<script>
import memberSearchDropdown from 'client/components/members/memberSearchDropdown';
export default {
props: ['challengeId', 'members', 'prize'],
components: {
memberSearchDropdown,
},
data () {
return {
winner: {},
};
},
computed: {
winnerText () {
if (!this.winner.profile) return this.$t('selectMember');
return this.winner.profile.name;
},
},
methods: {
selectMember (member) {
this.winner = member;
},
async closeChallenge () {
this.challenge = await this.$store.dispatch('challenges:selectChallengeWinner', {
challengeId: this.challengeId,
winnerId: this.winner._id,
});
this.$router.push('/challenges/myChallenges');
},
async deleteChallenge () {
if (!confirm('Are you sure you want to delete this challenge?')) return;
this.challenge = await this.$store.dispatch('challenges:deleteChallenge', {
challengeId: this.challengeId,
prize: this.prize,
});
this.$router.push('/challenges/myChallenges');
},
},
};
</script>
@@ -0,0 +1,199 @@
<template lang="pug">
.row
challenge-modal(v-on:createChallenge='challengeCreated')
sidebar(v-on:search="updateSearch", v-on:filter="updateFilters")
.col-12.col-md-10.standard-page
.row.header-row
.col-md-8.text-left
h1(v-once) {{$t('findChallenges')}}
.col-md-4
// @TODO: implement sorting span.dropdown-label {{ $t('sortBy') }}
b-dropdown(:text="$t('sort')", right=true)
b-dropdown-item(v-for='sortOption in sortOptions', :key="sortOption.value", @click='sort(sortOption.value)') {{sortOption.text}}
button.btn.btn-secondary.create-challenge-button.float-right(@click='createChallenge()')
.svg-icon.positive-icon(v-html="icons.positiveIcon")
span(v-once) {{$t('createChallenge')}}
.row
.no-challenges.text-center.col-md-6.offset-3(v-if='!loading && filteredChallenges.length === 0')
h2(v-once) {{$t('noChallengeMatchFilters')}}
.row
.col-12.col-md-6(v-for='challenge in filteredChallenges')
challenge-item(:challenge='challenge')
mugen-scroll(
:handler="infiniteScrollTrigger",
:should-handle="!loading && canLoadMore",
:threshold="1",
v-show="loading",
)
h2.col-12.loading(v-once) {{ $t('loading') }}
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
.header-row {
h1 {
color: $purple-200;
}
.create-challenge-button {
margin-left: 1em;
}
.positive-icon {
color: $green-10;
width: 10px;
display: inline-block;
margin-right: .5em;
}
}
.no-challenges {
color: $gray-200;
margin-top: 10em;
h2 {
color: $gray-200;
}
}
.loading {
text-align: center;
color: $purple-300;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import Sidebar from './sidebar';
import ChallengeItem from './challengeItem';
import challengeModal from './challengeModal';
import challengeUtilities from 'client/mixins/challengeUtilities';
import positiveIcon from 'assets/svg/positive.svg';
import MugenScroll from 'vue-mugen-scroll';
import debounce from 'lodash/debounce';
export default {
mixins: [challengeUtilities],
components: {
Sidebar,
ChallengeItem,
challengeModal,
MugenScroll,
},
data () {
return {
loading: true,
canLoadMore: true,
icons: Object.freeze({
positiveIcon,
}),
challenges: [],
sort: 'none',
sortOptions: [
{
text: this.$t('none'),
value: 'none',
},
{
text: this.$t('participants'),
value: 'participants',
},
{
text: this.$t('name'),
value: 'name',
},
{
text: this.$t('end_date'),
value: 'end_date',
},
{
text: this.$t('start_date'),
value: 'start_date',
},
],
search: '',
filters: {},
page: 0,
};
},
mounted () {
this.loadChallenges();
},
computed: {
...mapState({user: 'user.data'}),
filteredChallenges () {
return this.challenges;
},
},
methods: {
updateSearch (eventData) {
this.search = eventData.searchTerm;
this.page = 0;
this.loadChallenges();
},
updateFilters (eventData) {
this.filters = eventData;
this.page = 0;
this.loadChallenges();
},
createChallenge () {
this.$root.$emit('bv::show::modal', 'challenge-modal');
},
async loadChallenges () {
this.loading = true;
let categories = '';
if (this.filters.categories) {
categories = this.filters.categories.join(',');
}
let owned = '';
// @TODO: we skip ownership === 2 because it is the same as === 0 right now
if (this.filters.ownership && this.filters.ownership.length === 1) {
owned = this.filters.ownership[0];
}
const challenges = await this.$store.dispatch('challenges:getUserChallenges', {
page: this.page,
search: this.search,
categories,
owned,
});
if (this.page === 0) {
this.challenges = challenges;
} else {
this.challenges = this.challenges.concat(challenges);
}
// only show the load more Button if the max count was returned
this.canLoadMore = challenges.length === 10;
this.loading = false;
},
challengeCreated (challenge) {
this.challenges.push(challenge);
},
infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading)
if (this.canLoadMore) {
this.loading = true;
}
this.loadMore();
},
loadMore: debounce(function loadMoreDebounce () {
this.page += 1;
this.loadChallenges();
}, 1000),
},
};
</script>
@@ -0,0 +1,92 @@
<template lang="pug">
div
challenge-modal(:groupId='groupId', v-on:createChallenge='challengeCreated')
.row.no-quest-section(v-if='challenges.length === 0')
.col-12.text-center
.svg-icon.challenge-icon(v-html="icons.challengeIcon")
h4(v-once) {{ $t('haveNoChallenges') }}
p(v-once) {{ $t('challengeDetails') }}
button.btn.btn-secondary(@click='createChallenge()') {{ $t('createChallenge') }}
template(v-else)
challenge-item(v-for='challenge in challenges',:challenge='challenge',:key='challenge._id',:fullLayout='false')
.col-12.text-center
button.btn.btn-secondary(@click='createChallenge()') {{ $t('createChallenge') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.no-quest-section {
padding: 2em;
color: $gray-300;
h4 {
color: $gray-300;
}
p {
margin-bottom: 2em;
}
.svg-icon {
height: 30px;
width: 30px;
margin: 0 auto;
margin-bottom: 2em;
}
}
</style>
<script>
import challengeModal from './challengeModal';
import {mapState} from 'client/libs/store';
import markdownDirective from 'client/directives/markdown';
import challengeItem from './challengeItem';
import challengeIcon from 'assets/svg/challenge.svg';
export default {
props: ['groupId'],
components: {
challengeModal,
challengeItem,
},
computed: {
...mapState({user: 'user.data'}),
},
data () {
return {
challenges: [],
icons: Object.freeze({
challengeIcon,
}),
groupIdForChallenges: '',
};
},
directives: {
markdown: markdownDirective,
},
mounted () {
this.loadChallenges();
},
watch: {
async groupId () {
this.loadChallenges();
},
},
methods: {
async loadChallenges () {
this.groupIdForChallenges = this.groupId;
if (this.groupId === 'party' && this.user.party._id) this.groupIdForChallenges = this.user.party._id;
this.challenges = await this.$store.dispatch('challenges:getGroupChallenges', {groupId: this.groupIdForChallenges});
},
createChallenge () {
this.$root.$emit('bv::show::modal', 'challenge-modal');
},
challengeCreated (challenge) {
if (challenge.group._id !== this.groupIdForChallenges) return;
this.challenges.push(challenge);
},
},
};
</script>
@@ -0,0 +1,19 @@
<template lang="pug">
.row
secondary-menu.col-12
router-link.nav-link(:to="{name: 'myChallenges'}", :class="{'active': $route.name === 'myChallenges'}") {{ $t('myChallenges') }}
router-link.nav-link(:to="{name: 'findChallenges'}", :class="{'active': $route.name === 'findChallenges'}") {{ $t('findChallenges') }}
.col-12
router-view
</template>
<script>
import SecondaryMenu from 'client/components/secondaryMenu';
export default {
components: {
SecondaryMenu,
},
};
</script>
@@ -0,0 +1,45 @@
<template lang="pug">
b-modal#leave-challenge-modal(:title="$t('leaveChallenge')", size='sm', :hide-footer="true")
.modal-body
h2 {{ $t('confirmKeepChallengeTasks') }}
div
button.btn.btn-primary(@click='leaveChallenge("keep")') {{ $t('keepThem') }}
button.btn.btn-danger(@click='leaveChallenge("remove-all")') {{ $t('removeThem') }}
</template>
<style scoped>
.modal-body {
padding-bottom: 2em;
}
</style>
<script>
import findIndex from 'lodash/findIndex';
import { mapState } from 'client/libs/store';
import notifications from 'client/mixins/notifications';
export default {
props: ['challengeId'],
mixins: [notifications],
computed: {
...mapState({user: 'user.data'}),
},
methods: {
async leaveChallenge (keep) {
let index = findIndex(this.user.challenges, (id) => {
return id === this.challengeId;
});
this.user.challenges.splice(index, 1);
await this.$store.dispatch('challenges:leaveChallenge', {
challengeId: this.challengeId,
keep,
});
await this.$store.dispatch('tasks:fetchUserTasks', {forceLoad: true});
this.close();
},
close () {
this.$root.$emit('bv::hide::modal', 'leave-challenge-modal');
},
},
};
</script>
@@ -0,0 +1,231 @@
<template lang="pug">
.row
challenge-modal(v-on:createChallenge='challengeCreated')
sidebar(v-on:search="updateSearch", v-on:filter="updateFilters")
.col-10.standard-page
.row.header-row
.col-md-8.text-left
h1(v-once) {{$t('myChallenges')}}
.col-md-4
// @TODO: implement sorting span.dropdown-label {{ $t('sortBy') }}
b-dropdown(:text="$t('sort')", right=true)
b-dropdown-item(v-for='sortOption in sortOptions', :key="sortOption.value", @click='sort(sortOption.value)') {{sortOption.text}}
button.btn.btn-secondary.create-challenge-button.float-right(@click='createChallenge()')
.svg-icon.positive-icon(v-html="icons.positiveIcon")
span(v-once) {{$t('createChallenge')}}
.row
.no-challenges.text-center.col-md-6.offset-3(v-if='!loading && challenges.length === 0')
.svg-icon(v-html="icons.challengeIcon")
h2(v-once) {{$t('noChallengeTitle')}}
p(v-once) {{$t('challengeDescription1')}}
p(v-once) {{$t('challengeDescription2')}}
.row
.no-challenges.text-center.col-md-6.offset-3(v-if='!loading && challenges.length > 0 && filteredChallenges.length === 0')
h2(v-once) {{$t('noChallengeMatchFilters')}}
.row
.col-12.col-md-6(v-for='challenge in filteredChallenges')
challenge-item(:challenge='challenge')
mugen-scroll(
:handler="infiniteScrollTrigger",
:should-handle="!loading && canLoadMore",
:threshold="1",
v-show="loading",
)
h2.col-12.loading(v-once) {{ $t('loading') }}
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
.header-row {
h1 {
color: $purple-200;
}
.create-challenge-button {
margin-left: 1em;
}
.positive-icon {
color: $green-10;
width: 10px;
display: inline-block;
margin-right: .5em;
}
}
.no-challenges {
color: $gray-200;
margin-top: 10em;
h2 {
color: $gray-200;
}
.svg-icon {
color: #C3C0C7;
width: 88.7px;
margin: 1em auto;
}
}
.loading {
text-align: center;
color: $purple-300;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import Sidebar from './sidebar';
import ChallengeItem from './challengeItem';
import challengeModal from './challengeModal';
import challengeUtilities from 'client/mixins/challengeUtilities';
import challengeIcon from 'assets/svg/challenge.svg';
import positiveIcon from 'assets/svg/positive.svg';
import MugenScroll from 'vue-mugen-scroll';
import debounce from 'lodash/debounce';
export default {
mixins: [challengeUtilities],
components: {
Sidebar,
ChallengeItem,
challengeModal,
MugenScroll,
},
data () {
return {
icons: Object.freeze({
challengeIcon,
positiveIcon,
}),
loading: false,
canLoadMore: true,
challenges: [],
sort: 'none',
sortOptions: [
{
text: this.$t('none'),
value: 'none',
},
{
text: this.$t('participants'),
value: 'participants',
},
{
text: this.$t('name'),
value: 'name',
},
{
text: this.$t('end_date'),
value: 'end_date',
},
{
text: this.$t('start_date'),
value: 'start_date',
},
],
search: '',
filters: {},
page: 0,
};
},
mounted () {
this.loadChallenges();
},
computed: {
...mapState({user: 'user.data'}),
filteredChallenges () {
const filters = this.filters;
const user = this.user;
return this.challenges.filter(challenge => {
let isMember = true;
let filteringRole = filters.roles && filters.roles.length > 0;
if (filteringRole && filters.roles.indexOf('participating') !== -1) {
isMember = this.isMemberOfChallenge(user, challenge);
}
if (filteringRole && filters.roles.indexOf('not_participating') !== -1) {
isMember = !this.isMemberOfChallenge(user, challenge);
}
return isMember;
});
},
},
methods: {
updateSearch (eventData) {
this.search = eventData.searchTerm;
this.page = 0;
this.loadChallenges();
},
updateFilters (eventData) {
this.filters = eventData;
this.page = 0;
this.loadChallenges();
},
createChallenge () {
this.$store.state.challengeOptions.workingChallenge = {};
this.$root.$emit('bv::show::modal', 'challenge-modal');
},
async loadChallenges () {
this.loading = true;
let categories = '';
if (this.filters.categories) {
categories = this.filters.categories.join(',');
}
let owned = '';
// @TODO: we skip ownership === 2 because it is the same as === 0 right now
if (this.filters.ownership && this.filters.ownership.length === 1) {
owned = this.filters.ownership[0];
}
const challenges = await this.$store.dispatch('challenges:getUserChallenges', {
page: this.page,
search: this.search,
categories,
owned,
member: true,
});
if (this.page === 0) {
this.challenges = challenges;
} else {
this.challenges = this.challenges.concat(challenges);
}
// only show the load more Button if the max count was returned
this.canLoadMore = challenges.length === 10;
this.loading = false;
},
challengeCreated (challenge) {
this.challenges.push(challenge);
},
infiniteScrollTrigger () {
// show loading and wait until the loadMore debounced
// or else it would trigger on every scrolling-pixel (while not loading)
if (this.canLoadMore) {
this.loading = true;
}
this.loadMore();
},
loadMore: debounce(function loadMoreDebounce () {
this.page += 1;
this.loadChallenges();
}, 1000),
},
};
</script>
@@ -0,0 +1,160 @@
<template lang="pug">
.standard-sidebar.d-none.d-sm-block
.form-group
input.form-control.search(type="text", :placeholder="$t('search')", v-model='searchTerm')
form
h2(v-once) {{ $t('filter') }}
.form-group
h3 {{ $t('category') }}
.form-check(
v-for="group in categoryOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="categoryFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
.form-group(v-if='$route.name !== "findChallenges"')
h3 {{ $t('membership') }}
.form-check(
v-for="group in roleOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="roleFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
.form-group
h3 {{ $t('ownership') }}
.form-check(
v-for="group in ownershipOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="ownershipFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
</template>
<script>
import throttle from 'lodash/throttle';
export default {
data () {
return {
categoryFilters: [],
categoryOptions: [
{
label: 'habitica_official',
key: 'habitica_official',
},
{
label: 'academics',
key: 'academics',
},
{
label: 'advocacy_causes',
key: 'advocacy_causes',
},
{
label: 'creativity',
key: 'creativity',
},
{
label: 'entertainment',
key: 'entertainment',
},
{
label: 'finance',
key: 'finance',
},
{
label: 'health_fitness',
key: 'health_fitness',
},
{
label: 'hobbies_occupations',
key: 'hobbies_occupations',
},
{
label: 'location_based',
key: 'location_based',
},
{
label: 'mental_health',
key: 'mental_health',
},
{
label: 'getting_organized',
key: 'getting_organized',
},
{
label: 'self_improvement',
key: 'self_improvement',
},
{
label: 'spirituality',
key: 'spirituality',
},
{
label: 'time_management',
key: 'time_management',
},
],
roleFilters: [],
roleOptions: [
{
label: 'participating',
key: 'participating',
},
{
label: 'not_participating',
key: 'not_participating',
},
// {
// label: 'either',
// key: 'either',
// },
],
ownershipFilters: [],
ownershipOptions: [
{
label: 'owned',
key: 'owned',
},
{
label: 'not_owned',
key: 'not_owned',
},
// {
// label: 'either',
// key: 'either',
// },
],
searchTerm: '',
};
},
watch: {
categoryFilters: function categoryFilters () {
this.emitFilters();
},
roleFilters: function roleFilters () {
this.emitFilters();
},
ownershipFilters: function ownershipFilters () {
this.emitFilters();
},
searchTerm: throttle(function searchTerm (newSearch) {
this.$emit('search', {
searchTerm: newSearch,
});
}, 500),
},
methods: {
emitFilters () {
this.$emit('filter', {
categories: this.categoryFilters,
roles: this.roleFilters,
ownership: this.ownershipFilters,
});
},
},
};
</script>
@@ -0,0 +1,236 @@
<template lang="pug">
.autocomplete-selection(v-if='searchResults.length > 0', :style='autocompleteStyle')
.autocomplete-results.d-flex.align-items-center(
v-for='result in searchResults',
@click='select(result)',
@mouseenter='setHover(result)',
@mouseleave='resetSelection()',
:class='{"hover-background": result.hover}',
)
span
h3.profile-name(:class='userLevelStyle(result.msg)') {{ result.displayName }}
.svg-icon(v-html="tierIcon(result.msg)", v-if='showTierStyle(result.msg)')
span.username.ml-2(v-if='result.username', :class='{"hover-foreground": result.hover}') @{{ result.username }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/tiers.scss';
@import '~client/assets/scss/colors.scss';
.autocomplete-results {
padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef;
}
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
cursor: pointer;
}
.hover-foreground {
color: $purple-300 !important;
}
.profile-name {
display: inline-block;
font-size: 16px;
margin-bottom: 0rem;
}
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
.username {
color: $gray-200;
}
</style>
<script>
import groupBy from 'lodash/groupBy';
import styleHelper from 'client/mixins/styleHelper';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tier8 from 'assets/svg/tier-mod.svg';
import tier9 from 'assets/svg/tier-staff.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
export default {
props: ['selections', 'text', 'caretPosition', 'coords', 'chat', 'textbox'],
data () {
return {
atRegex: /(?!\b)@[\w-]*$/,
currentSearch: '',
searchActive: false,
searchEscaped: false,
currentSearchPosition: 0,
tmpSelections: [],
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
selected: null,
};
},
computed: {
autocompleteStyle () {
function heightToUse (textBox, topCoords) {
let textBoxHeight = textBox['user-entry'].clientHeight;
return topCoords < textBoxHeight ? topCoords + 30 : textBoxHeight + 10;
}
return {
top: `${heightToUse(this.textbox, this.coords.TOP)}px`,
left: `${this.coords.LEFT + 30}px`,
marginLeft: '-28px',
marginTop: '28px',
position: 'absolute',
minWidth: '100px',
zIndex: 100,
backgroundColor: 'white',
};
},
searchResults () {
if (!this.searchActive) return [];
if (!this.atRegex.exec(this.text)) return [];
this.currentSearch = this.atRegex.exec(this.text)[0];
this.currentSearch = this.currentSearch.substring(1, this.currentSearch.length);
return this.tmpSelections.filter((option) => {
return option.displayName.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1 || option.username && option.username.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1;
}).slice(0, 4);
},
},
mounted () {
this.grabUserNames();
},
watch: {
text (newText) {
if (!newText[newText.length - 1] || newText[newText.length - 1] === ' ') {
this.searchActive = false;
this.searchEscaped = false;
return;
}
if (newText[newText.length - 1] === '@') {
this.searchEscaped = false;
}
if (this.searchEscaped) return;
if (!this.atRegex.test(newText)) return;
this.searchActive = true;
},
chat () {
this.resetDefaults();
this.grabUserNames();
},
},
methods: {
resetDefaults () {
// Mounted is not called when switching between group pages because they have the
// the same parent component. So, reset the data
this.searchActive = false;
this.searchEscaped = false;
this.tmpSelections = [];
this.resetSelection();
},
grabUserNames () {
let usersThatMessage = groupBy(this.chat, 'user');
for (let userKey in usersThatMessage) {
let systemMessage = userKey === 'undefined';
if (!systemMessage && this.tmpSelections.indexOf(userKey) === -1) {
this.tmpSelections.push({
displayName: userKey,
username: usersThatMessage[userKey][0].username,
msg: {
backer: usersThatMessage[userKey][0].backer,
contributor: usersThatMessage[userKey][0].contributor,
},
hover: false,
});
}
}
},
showTierStyle (message) {
const isContributor = Boolean(message.contributor && message.contributor.level);
const isNPC = Boolean(message.backer && message.backer.npc);
return isContributor || isNPC;
},
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${message.contributor.level}`];
},
select (result) {
let newText = this.text;
const targetName = `${result.username || result.displayName} `;
newText = newText.replace(new RegExp(`${this.currentSearch}$`), targetName);
this.$emit('select', newText);
this.resetSelection();
},
setHover (result) {
this.resetSelection();
result.hover = true;
},
clearHover () {
for (const selection of this.searchResults) {
selection.hover = false;
}
},
resetSelection () {
this.clearHover();
this.selected = null;
},
selectNext () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null ?
0 :
(this.selected + 1) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
selectPrevious () {
if (this.searchResults.length > 0) {
this.clearHover();
this.selected = this.selected === null ?
this.searchResults.length - 1 :
(this.selected - 1 + this.searchResults.length) % this.searchResults.length;
this.searchResults[this.selected].hover = true;
}
},
makeSelection () {
if (this.searchResults.length > 0 && this.selected !== null) {
const result = this.searchResults[this.selected];
this.select(result);
}
},
cancel () {
this.searchActive = false;
this.searchEscaped = true;
this.resetSelection();
},
},
mixins: [styleHelper],
};
</script>
@@ -0,0 +1,288 @@
<template lang="pug">
div
.mentioned-icon(v-if='isUserMentioned')
.message-hidden(v-if='!inbox && user.contributor.admin && msg.flagCount') {{flagCountDescription}}
.card-body
user-link(:userId="msg.uuid", :name="msg.user", :backer="msg.backer", :contributor="msg.contributor")
p.time
span.mr-1(v-if="msg.username") @{{ msg.username }}
span.mr-1(v-if="msg.username")
span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }}&nbsp;
span(v-if="msg.client && user.contributor.level >= 4") ({{ msg.client }})
.text(v-html='atHighlight(parseMarkdown(msg.text))')
.reported(v-if="isMessageReported && (inbox === true)")
span(v-once) {{ $t('reportedMessage')}}
br
span(v-once) {{ $t('canDeleteNow') }}
hr
.d-flex(v-if='msg.id')
.action.d-flex.align-items-center(v-if='!inbox', @click='copyAsTodo(msg)')
.svg-icon(v-html="icons.copy")
div {{$t('copyAsTodo')}}
.action.d-flex.align-items-center(v-if='(inbox || (user.flags.communityGuidelinesAccepted && msg.uuid !== "system")) && (!isMessageReported || user.contributor.admin)', @click='report(msg)')
.svg-icon(v-html="icons.report", v-once)
div(v-once) {{$t('report')}}
.action.d-flex.align-items-center(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove()')
.svg-icon(v-html="icons.delete", v-once)
div(v-once) {{$t('delete')}}
.ml-auto.d-flex(v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}", v-if='!inbox')
.action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount > 0', :class='{active: msg.likes[user._id]}')
.svg-icon(v-html="icons.liked", :title='$t("liked")')
| +{{ likeCount }}
.action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount === 0', :class='{active: msg.likes[user._id]}')
.svg-icon(v-html="icons.like", :title='$t("like")')
span(v-if='!msg.likes[user._id] && !inbox') {{ $t('like') }}
</template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
padding: 0.1rem;
}
.at-text {
color: #6133b4;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss';
.mentioned-icon {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #bda8ff;
box-shadow: 0 1px 1px 0 rgba(26, 24, 29, 0.12);
position: absolute;
right: -.5em;
top: -.5em;
}
.message-hidden {
margin-left: 1.5em;
margin-top: 1em;
color: red;
}
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.time {
font-size: 12px;
color: #878190;
margin-bottom: 0.5rem;
}
.text {
font-size: 14px;
color: #4e4a57;
text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
}
}
.action {
display: inline-block;
color: #878190;
margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: #A5A1AC;
margin-right: .2em;
width: 16px;
}
}
.active {
color: $purple-300;
.svg-icon {
color: $purple-400;
}
}
.reported {
margin-top: 18px;
color: $red-50;
}
</style>
<script>
import axios from 'axios';
import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import max from 'lodash/max';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from 'client/libs/store';
import userLink from '../userLink';
import deleteIcon from 'assets/svg/delete.svg';
import copyIcon from 'assets/svg/copy.svg';
import likeIcon from 'assets/svg/like.svg';
import likedIcon from 'assets/svg/liked.svg';
import reportIcon from 'assets/svg/report.svg';
import { highlightUsers } from '../../libs/highlightUsers';
import { CHAT_FLAG_LIMIT_FOR_HIDING, CHAT_FLAG_FROM_SHADOW_MUTE } from '../../../common/script/constants';
export default {
components: {userLink},
props: {
msg: {},
inbox: {
type: Boolean,
default: false,
},
groupId: {},
},
data () {
return {
icons: Object.freeze({
like: likeIcon,
copy: copyIcon,
report: reportIcon,
delete: deleteIcon,
liked: likedIcon,
}),
reported: false,
};
},
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
date (value) {
// @TODO: Vue doesn't support this so we cant user preference
return moment(value).toDate().toString();
},
},
computed: {
...mapState({user: 'user.data'}),
isUserMentioned () {
const message = this.msg;
const user = this.user;
if (message.hasOwnProperty('highlight')) return message.highlight;
message.highlight = false;
const messageText = message.text.toLowerCase();
const displayName = user.profile.name;
const username = user.auth.local && user.auth.local.username;
const mentioned = max([messageText.indexOf(username.toLowerCase()), messageText.indexOf(displayName.toLowerCase())]);
if (mentioned === -1) return message.highlight;
const escapedDisplayName = escapeRegExp(displayName);
const escapedUsername = escapeRegExp(username);
const pattern = `@(${escapedUsername}|${escapedDisplayName})(\\b)`;
const precedingChar = messageText.substring(mentioned - 1, mentioned);
if (mentioned === 0 || precedingChar.trim() === '' || precedingChar === '@') {
let regex = new RegExp(pattern, 'i');
message.highlight = regex.test(messageText);
}
return message.highlight;
},
likeCount () {
const message = this.msg;
if (!message.likes) return 0;
let likeCount = 0;
for (let key in message.likes) {
let like = message.likes[key];
if (like) likeCount += 1;
}
return likeCount;
},
isMessageReported () {
return this.msg.flags && this.msg.flags[this.user.id] || this.reported;
},
flagCountDescription () {
if (!this.msg.flagCount) return '';
if (this.msg.flagCount < CHAT_FLAG_LIMIT_FOR_HIDING) return 'Message flagged once, not hidden';
if (this.msg.flagCount < CHAT_FLAG_FROM_SHADOW_MUTE) return 'Message hidden';
return 'Message hidden (shadow-muted)';
},
},
methods: {
async like () {
let message = cloneDeep(this.msg);
await this.$store.dispatch('chat:like', {
groupId: this.groupId,
chatId: message.id,
});
if (!message.likes[this.user._id]) {
message.likes[this.user._id] = true;
} else {
message.likes[this.user._id] = !message.likes[this.user._id];
}
this.$emit('message-liked', message);
this.$root.$emit('bv::hide::tooltip');
},
likeTooltip (likedStatus) {
if (!likedStatus) return this.$t('like');
},
copyAsTodo (message) {
this.$root.$emit('habitica::copy-as-todo', message);
},
report () {
this.$root.$on('habitica:report-result', data => {
if (data.ok) {
this.reported = true;
}
this.$root.$off('habitica:report-result');
});
this.$root.$emit('habitica::report-chat', {
message: this.msg,
groupId: this.groupId || 'privateMessage',
});
},
async remove () {
if (!confirm(this.$t('areYouSureDeleteMessage'))) return;
const message = this.msg;
this.$emit('message-removed', message);
if (this.inbox) {
await axios.delete(`/api/v4/inbox/messages/${message.id}`);
return;
}
await this.$store.dispatch('chat:deleteChat', {
groupId: this.groupId,
chatId: message.id,
});
},
atHighlight (text) {
return highlightUsers(text, this.user.auth.local.username, this.user.profile.name);
},
parseMarkdown (text) {
if (!text) return;
return habiticaMarkdown.render(String(text));
},
},
mounted () {
this.CHAT_FLAG_LIMIT_FOR_HIDING = CHAT_FLAG_LIMIT_FOR_HIDING;
this.CHAT_FLAG_FROM_SHADOW_MUTE = CHAT_FLAG_FROM_SHADOW_MUTE;
this.$emit('chat-card-mounted', this.msg.id);
},
};
</script>
@@ -0,0 +1,340 @@
<template lang="pug">
.container-fluid(ref="container")
.row
.col-12
copy-as-todo-modal(:group-type='groupType', :group-name='groupName', :group-id='groupId')
.row.loadmore
div(v-if="canLoadMore")
.loadmore-divider
button.btn.btn-secondary(@click='triggerLoad()') {{ $t('loadEarlierMessages') }}
.loadmore-divider
h2.col-12.loading(v-show="isLoading") {{ $t('loading') }}
div(v-for="(msg, index) in messages", v-if='chat && canViewFlag(msg)', :class='{row: inbox}')
.d-flex(v-if='user._id !== msg.uuid', :class='{"flex-grow-1": inbox}')
avatar.avatar-left(
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
:member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true",
:overrideTopPadding='"14px"',
:hideClassBadge='true',
@click.native="showMemberModal(msg.uuid)",
:class='{"inbox-avatar-left": inbox}'
)
.card(:class='{"col-10": inbox}')
chat-card(
:msg='msg',
:inbox='inbox',
:groupId='groupId',
@message-liked='messageLiked',
@message-removed='messageRemoved',
@show-member-modal='showMemberModal',
@chat-card-mounted='itemWasMounted')
.d-flex(v-if='user._id === msg.uuid', :class='{"flex-grow-1": inbox}')
.card(:class='{"col-10": inbox}')
chat-card(
:msg='msg',
:inbox='inbox',
:groupId='groupId',
@message-liked='messageLiked',
@message-removed='messageRemoved',
@show-member-modal='showMemberModal',
@chat-card-mounted='itemWasMounted')
avatar(
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
:member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true",
:hideClassBadge='true',
:overrideTopPadding='"14px"',
@click.native="showMemberModal(msg.uuid)",
:class='{"inbox-avatar-right": inbox}'
)
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.avatar {
width: 10%;
min-width: 7rem;
}
.loadmore {
justify-content: center;
> div {
display: flex;
width: 100%;
align-items: center;
button {
text-align: center;
color: $gray-50;
margin-top: 12px;
margin-bottom: 24px;
}
}
}
.loadmore-divider {
height: 1px;
background-color: $gray-500;
flex: 1;
margin-left: 24px;
margin-right: 24px;
&:last-of-type {
margin-right: 0;
}
}
.avatar-left {
margin-left: -1.5rem;
margin-right: 2rem;
}
.inbox-avatar-left {
margin-left: -1rem;
margin-right: 2.5rem;
min-width: 5rem;
}
.inbox-avatar-right {
margin-left: -3.5rem;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.hr-middle {
font-size: 16px;
font-weight: bold;
font-family: 'Roboto Condensed';
line-height: 1.5;
text-align: center;
color: $gray-200;
background-color: $gray-700;
padding: .2em;
margin-top: .2em;
display: inline-block;
width: 100px;
}
.card {
border: 0px;
margin-bottom: .5em;
padding: 0rem;
width: 90%;
}
.message-scroll .d-flex {
min-width: 1px;
}
</style>
<script>
import moment from 'moment';
import axios from 'axios';
import { mapState } from 'client/libs/store';
import debounce from 'lodash/debounce';
import findIndex from 'lodash/findIndex';
import Avatar from '../avatar';
import copyAsTodoModal from './copyAsTodoModal';
import chatCard from './chatCard';
export default {
props: {
chat: {},
inbox: {
type: Boolean,
default: false,
},
groupType: {},
groupId: {},
groupName: {},
isLoading: Boolean,
canLoadMore: Boolean,
},
components: {
copyAsTodoModal,
chatCard,
Avatar,
},
mounted () {
this.loadProfileCache();
},
created () {
window.addEventListener('scroll', this.handleScroll);
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll);
},
data () {
return {
currentDayDividerDisplay: moment().day(),
cachedProfileData: {},
currentProfileLoadedCount: 0,
currentProfileLoadedEnd: 10,
loading: false,
handleScrollBack: false,
lastOffset: -1,
};
},
computed: {
...mapState({user: 'user.data'}),
// @TODO: We need a different lazy load mechnism.
// But honestly, adding a paging route to chat would solve this
messages () {
this.loadProfileCache();
return this.chat;
},
},
methods: {
handleScroll () {
this.loadProfileCache(window.scrollY / 1000);
},
async triggerLoad () {
const container = this.$refs.container;
// get current offset
this.lastOffset = container.scrollTop - (container.scrollHeight - container.clientHeight);
// disable scroll
container.style.overflowY = 'hidden';
const canLoadMore = this.inbox && !this.isLoading && this.canLoadMore;
if (canLoadMore) {
await this.$emit('triggerLoad');
this.handleScrollBack = true;
}
},
canViewFlag (message) {
if (message.uuid === this.user._id) return true;
if (!message.flagCount || message.flagCount < 2) return true;
return this.user.contributor.admin;
},
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
this._loadProfileCache(screenPosition);
}, 1000),
async _loadProfileCache (screenPosition) {
if (this.loading) return;
this.loading = true;
let promises = [];
const noProfilesLoaded = Object.keys(this.cachedProfileData).length === 0;
// @TODO: write an explination
// @TODO: Remove this after enough messages are cached
if (!noProfilesLoaded && screenPosition && Math.floor(screenPosition) + 1 > this.currentProfileLoadedEnd / 10) {
this.currentProfileLoadedEnd = 10 * (Math.floor(screenPosition) + 1);
} else if (!noProfilesLoaded && screenPosition) {
return;
}
let aboutToCache = {};
this.messages.forEach(message => {
let uuid = message.uuid;
if (message.userStyles) {
this.$set(this.cachedProfileData, uuid, message.userStyles);
}
if (Boolean(uuid) && !this.cachedProfileData[uuid] && !aboutToCache[uuid]) {
if (uuid === 'system' || this.currentProfileLoadedCount === this.currentProfileLoadedEnd) return;
aboutToCache[uuid] = {};
promises.push(axios.get(`/api/v4/members/${uuid}`));
this.currentProfileLoadedCount += 1;
}
});
let results = await Promise.all(promises);
results.forEach(result => {
// We could not load the user. Maybe they were deleted. So, let's cache empty so we don't try again
if (!result || !result.data || result.status >= 400) {
return;
}
let userData = result.data.data;
this.$set(this.cachedProfileData, userData._id, userData);
});
// Merge in any attempts that were rejected so we don't attempt again
for (let uuid in aboutToCache) {
if (!this.cachedProfileData[uuid]) {
this.$set(this.cachedProfileData, uuid, {rejected: true});
}
}
this.loading = false;
},
displayDivider (message) {
if (this.currentDayDividerDisplay !== moment(message.timestamp).day()) {
this.currentDayDividerDisplay = moment(message.timestamp).day();
return true;
}
return false;
},
async showMemberModal (memberId) {
let profile = this.cachedProfileData[memberId];
if (!profile._id) {
const result = await this.$store.dispatch('members:fetchMember', { memberId });
if (result.response && result.response.status === 404) {
return this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('messageDeletedUser'),
type: 'error',
timeout: false,
});
} else {
this.cachedProfileData[memberId] = result.data.data;
profile = result.data.data;
}
}
// Open the modal only if the data is available
if (profile && !profile.rejected) {
this.$router.push({name: 'userProfile', params: {userId: profile._id}});
}
},
itemWasMounted: debounce(function itemWasMounted () {
if (this.handleScrollBack) {
this.handleScrollBack = false;
const container = this.$refs.container;
const offset = container.scrollHeight - container.clientHeight;
const newOffset = offset + this.lastOffset;
container.scrollTo(0, newOffset);
// enable scroll again
container.style.overflowY = 'scroll';
}
}, 50),
messageLiked (message) {
const chatIndex = findIndex(this.chat, chatMessage => {
return chatMessage.id === message.id;
});
this.chat.splice(chatIndex, 1, message);
},
messageRemoved (message) {
if (this.inbox) {
this.$emit('message-removed', message);
return;
}
const chatIndex = findIndex(this.chat, chatMessage => {
return chatMessage.id === message.id;
});
this.chat.splice(chatIndex, 1);
},
},
};
</script>
@@ -0,0 +1,78 @@
<template lang="pug">
b-modal#copyAsTodo(:title="$t('copyMessageAsToDo')", :hide-footer="true", size='md')
.form-group
input.form-control(type='text', v-model='task.text')
.form-group
textarea.form-control(rows='5', v-model='task.notes' focus-element='true')
hr
task(v-if='task._id', :isUser="isUser", :task="task")
.modal-footer
button.btn.btn-secondary(@click='close()') {{ $t('close') }}
button.btn.btn-primary(@click='saveTodo()') {{ $t('submit') }}
</template>
<script>
import { mapActions } from 'client/libs/store';
import markdownDirective from 'client/directives/markdown';
import notificationsMixin from 'client/mixins/notifications';
import Task from 'client/components/tasks/task';
import taskDefaults from 'common/script/libs/taskDefaults';
import { TAVERN_ID } from '../../../common/script/constants';
const baseUrl = 'https://habitica.com';
export default {
directives: {
markdown: markdownDirective,
},
components: {
Task,
},
mixins: [notificationsMixin],
props: ['copyingMessage', 'groupType', 'groupName', 'groupId'],
data () {
return {
isUser: true,
task: {},
};
},
mounted () {
this.$root.$on('habitica::copy-as-todo', message => {
const notes = `${message.user || 'system message'}${message.user ? ' wrote' : ''} in [${this.groupName}](${this.groupPath()})`;
const newTask = {
text: message.text,
type: 'todo',
notes,
};
this.task = taskDefaults(newTask, this.$store.state.user.data);
this.$root.$emit('bv::show::modal', 'copyAsTodo');
});
},
destroyed () {
this.$root.$off('habitica::copy-as-todo');
},
methods: {
...mapActions({
createTask: 'tasks:create',
}),
groupPath () {
if (this.groupId === TAVERN_ID) {
return `${baseUrl}/groups/tavern`;
} else if (this.groupType === 'party') {
return `${baseUrl}/party`;
} else {
return `${baseUrl}/groups/guild/${this.groupId}`;
}
},
close () {
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
},
saveTodo () {
this.createTask(this.task);
this.text(this.$t('messageAddedAsToDo'));
this.$root.$emit('bv::hide::modal', 'copyAsTodo');
},
},
};
</script>
@@ -0,0 +1,137 @@
<template lang="pug">
b-modal#report-flag(:title='$t("abuseFlagModalHeading")', size='md', :hide-footer='true')
.modal-body
strong(v-html="$t('abuseFlagModalHeading', reportData)")
blockquote
div(v-markdown='abuseObject.text')
div
strong {{$t('whyReportingPost')}}
span.optional {{$t('optional')}}
textarea.form-control(v-model='reportComment', :placeholder='$t("whyReportingPostPlaceholder")')
small(v-html="$t('abuseFlagModalBody', abuseFlagModalBody)")
.footer.text-center
button.pull-left.btn.btn-danger(@click='clearFlagCount()', v-if='user.contributor.admin && abuseObject.flagCount > 0')
| Reset Flag Count
a.cancel-link(@click.prevent='close()') {{ $t('cancel') }}
button.btn.btn-danger(@click='reportAbuse()') {{ $t('report') }}
</template>
<style>
#report-flag h5 {
color: #f23035;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.modal-body {
margin-top: 1em;
}
blockquote {
border-radius: 2px;
background-color: #f4f4f4;
padding: 1em;
margin-top: 1em;
}
textarea {
margin-top: 1em;
margin-bottom: 1em;
border-radius: 2px;
border: solid 1px $gray-400;
min-height: 106px;
}
.footer {
padding: 1em;
padding-bottom: 2em;
}
a.cancel-link {
color: $blue-10;
margin-right: .5em;
}
.optional {
color: $gray-200;
float: right;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import notifications from 'client/mixins/notifications';
import markdownDirective from 'client/directives/markdown';
export default {
mixins: [notifications],
directives: {
markdown: markdownDirective,
},
computed: {
...mapState({user: 'user.data'}),
reportData () {
let reportMessage = this.abuseObject.user;
let isSystemMessage = this.abuseObject.uuid === 'system';
if (isSystemMessage) reportMessage = this.$t('systemMessage');
return {
name: `<span class='text-danger'>${reportMessage}</span>`,
};
},
},
data () {
let abuseFlagModalBody = {
firstLinkStart: '<a href="/static/community-guidelines" target="_blank">',
secondLinkStart: '<a href="/static/terms" target="_blank">',
linkEnd: '</a>',
};
return {
abuseFlagModalBody,
abuseObject: '',
groupId: '',
reportComment: '',
};
},
created () {
this.$root.$on('habitica::report-chat', this.handleReport);
},
destroyed () {
this.$root.$off('habitica::report-chat', this.handleReport);
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'report-flag');
},
async reportAbuse () {
this.text(this.$t(this.groupId === 'privateMessage' ? 'pmReported' : 'abuseReported'));
let result = await this.$store.dispatch('chat:flag', {
groupId: this.groupId,
chatId: this.abuseObject.id,
comment: this.reportComment,
});
this.$root.$emit('habitica:report-result', result);
this.close();
},
async clearFlagCount () {
await this.$store.dispatch('chat:clearFlagCount', {
groupId: this.groupId,
chatId: this.abuseObject.id,
});
this.close();
},
handleReport (data) {
if (!data.message || !data.groupId) return;
this.abuseObject = data.message;
this.groupId = data.groupId;
this.reportComment = '';
this.$root.$emit('bv::show::modal', 'report-flag');
},
},
};
</script>
@@ -0,0 +1,963 @@
<template lang="pug">
b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true', :hide-footer='true', :modal-class="{'page-2':modalPage > 1 && !editing}")
.section.row.welcome-section(v-if='modalPage === 1 && !editing')
.col-6.offset-3.text-center
h3(v-once) {{$t('welcomeTo')}}
.svg-icon.logo(v-html='icons.logoPurple')
.avatar-section.row(v-if='modalPage > 1', :class='{"page-2": modalPage === 2}')
.col-6.offset-3
.user-creation-bg(v-if='!editing')
avatar(:member='user', :avatarOnly='!editing', :class='{"edit-avatar": editing}')
.section(v-if='modalPage === 2', :class='{"edit-modal": editing}')
// @TODO Implement in V2 .section.row
.col-12.text-center
button.btn.btn-secondary(v-once) {{$t('randomize')}}
#options-nav.container.section.text-center.customize-menu
.row
.menu-container(@click='changeTopPage("body", "size")', :class='{"col-3": !editing, "col-2 offset-1": editing, active: activeTopPage === "body"}')
.menu-item
.svg-icon(v-html='icons.bodyIcon')
strong(v-once) {{$t('bodyBody')}}
.indicator
.menu-container(@click='changeTopPage("skin", "color")', :class='{"col-3": !editing, "col-2": editing, active: activeTopPage === "skin"}')
.menu-item
.svg-icon(v-html='icons.skinIcon')
strong(v-once) {{$t('skin')}}
.indicator
.menu-container(@click='changeTopPage("hair", "color")', :class='{"col-3": !editing, "col-2": editing, active: activeTopPage === "hair"}')
.menu-item
.svg-icon(v-html='icons.hairIcon')
strong(v-once) {{$t('hair')}}
.indicator
.menu-container(@click='changeTopPage("extra", "glasses")', :class='{"col-3": !editing, "col-2": editing, active: activeTopPage === "extra"}')
.menu-item
.svg-icon(v-html='icons.accessoriesIcon')
strong(v-once) {{$t('extra')}}
.indicator
.menu-container.col-2(@click='changeTopPage("backgrounds", "2019")', v-if='editing', :class='{active: activeTopPage === "backgrounds"}')
.menu-item
.svg-icon(v-html='icons.backgroundsIcon')
strong(v-once) {{$t('backgrounds')}}
.indicator
body-settings(
v-if='activeTopPage === "body"',
:editing="editing",
)
skin-settings(
v-if='activeTopPage === "skin"',
:editing="editing",
)
hairSettings(
v-if='activeTopPage === "hair"',
:editing="editing"
)
extraSettings(
v-if='activeTopPage === "extra"',
:editing="editing"
)
#backgrounds.section.container.customize-section(v-if='activeTopPage === "backgrounds"')
.row.title-row
toggle-switch.backgroundFilterToggle(:label="'Hide locked backgrounds'", v-model='filterBackgrounds')
.row.text-center.title-row(v-if='!filterBackgrounds')
strong {{backgroundShopSets[0].text}}
.row.title-row(v-if='!filterBackgrounds')
.col-12(v-if='showPlainBackgroundBlurb(backgroundShopSets[0].identifier, backgroundShopSets[0].items)') {{ $t('incentiveBackgroundsUnlockedWithCheckins') }}
.col-2(v-for='bg in backgroundShopSets[0].items',
@click='unlock("background." + bg.key)',
:popover-title='bg.text',
:popover='bg.notes',
popover-trigger='mouseenter')
.incentive-background(:class='[`background_${bg.key}`]')
.small-rectangle
sub-menu.text-center(:items="bgSubMenuItems", :activeSubPage="activeSubPage", @changeSubPage="changeSubPage($event)")
.row.customize-menu(v-if='!filterBackgrounds' v-for='(sets, key) in backgroundShopSetsByYear')
.row.background-set(v-for='set in sets', v-if='activeSubPage === key')
.col-8.offset-2.text-center.set-title
strong {{set.text}}
.col-4.text-center.customize-option.background-button(v-for='bg in set.items',
@click='!user.purchased.background[bg.key] ? backgroundSelected(bg) : unlock("background." + bg.key)',
:popover-title='bg.text',
:popover='bg.notes',
popover-trigger='mouseenter')
.background(:class='[`background_${bg.key}`, backgroundLockedStatus(bg.key)]')
i.glyphicon.glyphicon-lock(v-if='!user.purchased.background[bg.key]')
.purchase-background.single(v-if='!user.purchased.background[bg.key]')
.svg-icon.gem(v-html='icons.gem')
span.price 7
span.badge.badge-pill.badge-item.badge-svg(
:class="{'item-selected-badge': isBackgroundPinned(bg), 'hide': !isBackgroundPinned(bg)}",
@click.prevent.stop="togglePinned(bg)",
v-if='!user.purchased.background[bg.key]'
)
span.svg-icon.inline.icon-12.color(v-html="icons.pin")
.purchase-background.set(v-if='!ownsSet("background", set.items) && set.identifier !== "incentiveBackgrounds"' @click='unlock(setKeys("background", set.items))')
span.label {{ $t('purchaseAll') }}
.svg-icon.gem(v-html='icons.gem')
span.price 15
.row.customize-menu(v-if='filterBackgrounds')
.col-4.text-center.customize-option.background-button(v-for='(bg) in ownedBackgrounds'
@click='unlock("background." + bg.key)',
:popover-title='bg.text',
:popover='bg.notes',
popover-trigger='mouseenter')
.background(:class='[`background_${bg.key}`, backgroundLockedStatus(bg.key)]'
)
.container.interests-section(v-if='modalPage === 3 && !editing')
.section.row
.col-12.text-center
h2 {{ $t('wantToWorkOn') }}
.section.row
.col-6
.task-option
.custom-control.custom-checkbox
input.custom-control-input#work(type="checkbox", value='work', v-model='taskCategories')
label.custom-control-label(v-once, for="work") {{ $t('work') }}
.task-option
.custom-control.custom-checkbox
input.custom-control-input#excercise(type="checkbox", value='exercise', v-model='taskCategories')
label.custom-control-label(v-once, for="excercise") {{ $t('exercise') }}
.task-option
.custom-control.custom-checkbox
input.custom-control-input#health_wellness(type="checkbox", value='health_wellness', v-model='taskCategories')
label.custom-control-label(v-once, for="health_wellness") {{ $t('health_wellness') }}
.task-option
.custom-control.custom-checkbox
input.custom-control-input#school(type="checkbox", value='school', v-model='taskCategories')
label.custom-control-label(v-once, for="school") {{ $t('school') }}
.col-6
.task-option
.custom-control.custom-checkbox
input.custom-control-input#chores(type="checkbox", value='chores', v-model='taskCategories')
label.custom-control-label(v-once, for="chores") {{ $t('chores') }}
.task-option
.custom-control.custom-checkbox
input.custom-control-input#creativity(type="checkbox", value='creativity', v-model='taskCategories')
label.custom-control-label(v-once, for="creativity") {{ $t('creativity') }}
.task-option
.custom-control.custom-checkbox
input.custom-control-input#self_care(type="checkbox", value='self_care', v-model='taskCategories')
label.custom-control-label(v-once, for="self_care") {{ $t('self_care') }}
.section.d-flex.justify-content-center.justin-outer-section(:class='{top: modalPage > 1}', v-if='!editing')
.justin-section.d-flex.align-items-center
.featured-label
span.rectangle
span.text Justin
span.rectangle
.justin-message
.corner-decoration(:style="{top: '-2px', right: '-2px'}")
.corner-decoration(:style="{top: '-2px', left: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', right: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', left: '-2px'}")
div(v-if='modalPage === 1')
p(v-once, v-html='$t("justinIntroMessage1")')
p(v-once) {{ $t('justinIntroMessageUsername') }}
div(v-if='modalPage === 2')
p {{ $t('justinIntroMessageAppearance') }}
div(v-if='modalPage === 3')
p(v-once) {{ $t('justinIntroMessage3') }}
.npc-justin-textbox
.section.mr-5.ml-5.first-page-footer(v-if='modalPage === 1')
username-form(@usernameConfirmed='modalPage += 1', :avatarIntro='true')
.small.text-center(v-html="$t('usernameTOSRequirements')")
.section.container.footer(v-if='!editing && !(modalPage === 1)')
.footer-left
div.prev-outer(v-if='modalPage > 1', @click='prev()')
.prev-arrow.svg-icon(v-html='icons.arrowLeft')
.prev(v-once) {{ $t('prev') }}
.footer-center.text-center.circles
.circle(:class="{active: modalPage === 1}")
.circle(:class="{active: modalPage === 2}")
.circle(:class="{active: modalPage === 3}")
.footer-right
div.next-outer(v-if='modalPage < 3', @click='next()')
.next(v-once) {{$t('next')}}
.next-arrow.svg-icon(v-html='icons.arrowRight')
div.next-outer(v-if='modalPage === 3 && !loading', @click='done()', :class="{disabled: taskCategories.length === 0}")
.next(v-once) {{$t('finish')}}
.next-arrow.svg-icon(v-html='icons.arrowRight')
</template>
<style lang="scss">
@import '~client/assets/scss/colors.scss';
$dialogMarginTop: 56px;
$userCreationBgHeight: 105px;
/* @TODO do not rely on avatar-modal___BV_modal_body_,
it already changed once when bootstrap-vue reached version 1 */
#avatar-modal___BV_modal_body_, #avatar-modal___BV_modal_body_ {
padding: 0;
}
.page-2 {
#avatar-modal___BV_modal_body_ {
margin-top: $dialogMarginTop;
}
&#avatar-modal {
.modal-dialog.modal-md {
margin-top: 186px;
}
}
}
#avatar-modal {
.first-page-footer {
margin-bottom: 32px;
}
.customize-section {
text-align: center;
padding-bottom: 2em;
}
#creator-background {
background-color: $purple-200;
}
.corner-decoration {
position: absolute;
width: 6px;
height: 6px;
background-color: #ffbe5d;
border: inherit;
outline: inherit;
}
.small {
color: $gray-200;
}
h3 {
font-size: 20px;
font-weight: normal;
color: $gray-200;
}
.purchase-all {
margin-bottom: 1em;
}
.edit-modal {
margin-top: 10em;
}
.row.sub-menu + .row.sub-menu {
margin-top: 0.5em;
}
.welcome-section {
margin-top: 2.5em;
margin-bottom: 2.5em;
}
.logo {
width: 190px;
margin: 0 auto 1.25em;
}
.user-creation-bg {
background-image: url('~client/assets/creator/creator-hills-bg.png');
height: $userCreationBgHeight;
width: 219px;
margin: 0 auto;
}
.avatar {
position: absolute !important; // was overwritten in production build
top: -22px;
left: 4em;
}
.top {
position: absolute;
top: -($dialogMarginTop + $userCreationBgHeight - 16px);
right: 50%;
left: 50%;
}
.justin-outer-section:not(.top) {
margin-bottom: 24px;
}
.avatar-section {
margin-bottom: 30px;
}
.edit-avatar {
left: 9.2em;
}
.justin-section {
position: relative;
}
.justin-message {
border-color: #ffa623;
border-style: solid;
border-width: 2px;
outline-color: #b36213;
outline-style: solid;
outline-width: 2px;
position: relative;
padding: 2em;
margin: 2px;
height: 100%;
width: 400px;
background: $gray-700;
p {
margin: auto;
}
p + p {
margin-top: 1em;
}
}
.npc-justin-textbox {
position: absolute;
right: 1rem;
top: -3.1rem;
width: 48px;
height: 48px;
background-image: url('~client/assets/images/justin_textbox.png');
}
.featured-label {
position: absolute;
top: -1rem;
left: 1.5rem;
border-radius: 2px;
margin: auto;
.text {
font-size: 12px;
min-height: auto;
color: $white;
}
}
.circles {
align-self: flex-end;
}
.circle {
width: 8px;
height: 8px;
background-color: #d8d8d8;
border-radius: 50%;
display: inline-block;
margin-right: 1em;
&:last-of-type {
margin-right: 0;
}
}
.circle.active {
background-color: $purple-300;
}
.customize-menu {
.menu-item .svg-icon {
width: 32px;
height: 32px;
margin: 0 auto;
}
.menu-container {
color: #a5a1ac;
}
.menu-container:hover, .menu-container.active {
cursor: pointer;
color: $purple-300;
}
.indicator {
display: none;
}
.menu-container.active .indicator{
width: 0px;
height: 0px;
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-bottom: 12px solid $gray-700;
display: block;
margin: 0 auto;
}
}
.text-center {
.gem-lock, .gold-lock {
display: inline-block;
margin-right: 1em;
margin-bottom: 1.6em;
vertical-align: bottom;
}
}
.gem-lock, .gold-lock {
.svg-icon {
width: 16px;
}
span {
font-weight: bold;
margin-left: .5em;
}
.svg-icon, span {
display: inline-block;
vertical-align: bottom;
}
}
.gem-lock span {
color: $green-10
}
.gold-lock span {
color: $yellow-10
}
.customize-section {
background-color: #f9f9f9;
min-height: 280px;
}
.interests-section {
margin-top: 3em;
margin-bottom: 60px;
.task-option {
margin: 0 auto;
width: 70%;
}
}
#backgrounds {
padding-top: 12px;
.title-row {
margin-bottom: 1em;
}
.backgroundFilterToggle {
margin-left: auto;
margin-right: auto;
}
.set-title {
margin-top: 1em;
margin-bottom: 1em;
}
.background {
margin: 0 auto;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
border-radius: 2px;
}
strong {
margin: 0 auto;
}
.incentive-background {
background-image: none;
width: 68px;
height: 68px;
border-radius: 8px;
background-color: #92b6bd;
margin: 0 auto;
padding-top: .3em;
.small-rectangle {
width: 60px;
height: 40px;
border-radius: 4px;
margin: 0 auto;
opacity: .6;
background: white;
}
}
.background_violet {
background-color: #a993ed;
}
.background_blue {
background-color: #92b6bd;
}
.background_green {
background-color: #92bd94;
}
.background_purple {
background-color: #9397bd;
}
.background_red {
background-color: #b77e80;
}
.background_yellow {
background-color: #bcbb91;
}
.incentive-background:hover {
cursor: pointer;
}
.background:hover {
cursor: pointer;
}
.purchase-background {
margin: 0 auto;
background: #fff;
padding: 0.5em;
border-radius: 0 0 2px 2px;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
cursor: pointer;
span {
font-weight: bold;
font-size: 12px;
}
span.price {
color: #24cc8f;
}
.gem, .coin {
width: 16px;
}
&.single {
width: 141px;
}
&.set {
width: 100%;
span {
font-size: 14px;
}
.gem, .coin {
width: 20px;
}
}
}
.gem, .coin {
margin: 0 .5em;
display: inline-block;
vertical-align: bottom;
}
.background-set {
width: 100%;
margin: 10px;
background-color: #edecee;
border-radius: 2px;
}
}
.footer {
margin-top: 24px;
padding-bottom: 24px;
padding-left: 24px;
padding-right: 24px;
bottom: 0;
width: 100%;
display: flex;
* {
transition: none !important;
}
.footer-left, .footer-right {
flex: auto;
flex-grow: 0;
width: 30%
}
.footer-center {
flex: 1;
}
.prev, .next {
color: $gray-300;
font-weight: bold;
display: inline-block;
padding: 0.4em;
}
.prev {
padding-left: 16px;
}
.next {
padding-right: 16px;
}
.prev-outer {
white-space: nowrap;
&:hover {
cursor: pointer;
.prev {
color: $purple-300;
}
.prev-arrow {
path {
fill: $purple-300;
}
}
}
}
.prev-arrow, .next-arrow {
width: 32px;
height: 32px;
display: inline-block;
vertical-align: bottom;
}
.next-outer {
white-space: nowrap;
flex: 1;
text-align: right;
&:hover {
cursor: pointer;
.next {
color: $purple-300;
}
.next-arrow {
path {
fill: $purple-300;
}
}
}
}
}
.badge-svg {
left: calc((100% - 18px) / 2);
cursor: pointer;
color: $gray-400;
background: $white;
padding: 4.5px 6px;
&.item-selected-badge {
background: $purple-300;
color: $white;
}
}
.icon-12 {
width: 12px;
height: 12px;
}
span.badge.badge-pill.badge-item.badge-svg:not(.item-selected-badge) {
color: #a5a1ac;
}
span.badge.badge-pill.badge-item.badge-svg.hide {
display: none;
}
.background-button {
margin-bottom: 15px;
}
.background-button:hover {
span.badge.badge-pill.badge-item.badge-svg.hide {
display: block;
}
}
}
</style>
<script>
import axios from 'axios';
import map from 'lodash/map';
import { mapState } from 'client/libs/store';
import avatar from './avatar';
import usernameForm from './settings/usernameForm';
import { getBackgroundShopSets } from '../../common/script/libs/shops';
import guide from 'client/mixins/guide';
import notifications from 'client/mixins/notifications';
import toggleSwitch from 'client/components/ui/toggleSwitch';
import bodySettings from './avatarModal/body-settings';
import skinSettings from './avatarModal/skin-settings';
import hairSettings from './avatarModal/hair-settings';
import extraSettings from './avatarModal/extra-settings';
import subMenu from './avatarModal/sub-menu';
import logoPurple from 'assets/svg/logo-purple.svg';
import bodyIcon from 'assets/svg/body.svg';
import accessoriesIcon from 'assets/svg/accessories.svg';
import skinIcon from 'assets/svg/skin.svg';
import hairIcon from 'assets/svg/hair.svg';
import backgroundsIcon from 'assets/svg/backgrounds.svg';
import gem from 'assets/svg/gem.svg';
import gold from 'assets/svg/gold.svg';
import pin from 'assets/svg/pin.svg';
import arrowRight from 'assets/svg/arrow_right.svg';
import arrowLeft from 'assets/svg/arrow_left.svg';
import isPinned from 'common/script/libs/isPinned';
import {avatarEditorUtilies} from '../mixins/avatarEditUtilities';
import content from 'common/script/content/index';
export default {
mixins: [guide, notifications, avatarEditorUtilies],
components: {
avatar,
toggleSwitch,
usernameForm,
bodySettings,
skinSettings,
hairSettings,
extraSettings,
subMenu,
},
mounted () {
if (this.editing) this.modalPage = 2;
// Buy modal is global, so we listen at root. I'd like to not
this.$root.$on('buyModal::boughtItem', this.backgroundPurchased);
},
data () {
let backgroundShopSets = getBackgroundShopSets();
return {
loading: false,
backgroundShopSets,
backgroundUpdate: new Date(),
filterBackgrounds: false,
icons: Object.freeze({
logoPurple,
bodyIcon,
accessoriesIcon,
skinIcon,
hairIcon,
backgroundsIcon,
gem,
pin,
gold,
arrowRight,
arrowLeft,
}),
modalPage: 1,
activeTopPage: 'body',
activeSubPage: 'size',
taskCategories: [],
skinSubMenuItems: [
{
id: 'color',
label: this.$t('color'),
},
],
bgSubMenuItems: ['2019', '2018', '2017', '2016', '2015', '2014'].map(y =>
({
id: y,
label: y,
})
),
};
},
watch: {
editing () {
if (this.editing) this.modalPage = 2;
},
startingPage () {
if (!this.$store.state.avatarEditorOptions.startingPage) return;
this.activeTopPage = this.$store.state.avatarEditorOptions.startingPage;
this.activeSubPage = this.$store.state.avatarEditorOptions.subpage;
this.$store.state.avatarEditorOptions.startingPage = '';
this.$store.state.avatarEditorOptions.subpage = '';
},
},
computed: {
...mapState({user: 'user.data'}),
editing () {
return this.$store.state.avatarEditorOptions.editingUser;
},
startingPage () {
return this.$store.state.avatarEditorOptions.startingPage;
},
backgroundShopSetsByYear () {
// @TODO: add dates to backgrounds
let backgroundShopSetsByYear = {
2014: [],
2015: [],
2016: [],
2017: [],
2018: [],
2019: [],
};
// Hack to force update for now until we restructure the data
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
this.backgroundShopSets.forEach((set) => {
let year = set.identifier.substr(set.identifier.length - 4);
if (!backgroundShopSetsByYear[year]) return;
let setOwnedByUser = false;
for (let key in set.items) {
if (this.user.purchased.background[key]) setOwnedByUser = true;
}
set.userOwns = setOwnedByUser;
backgroundShopSetsByYear[year].push(set);
});
return backgroundShopSetsByYear;
},
ownedBackgrounds () {
let ownedBackgrounds = [];
// Hack to force update for now until we restructure the data
let backgroundUpdate = this.backgroundUpdate; // eslint-disable-line
this.backgroundShopSets.forEach((set) => {
set.items.forEach((bg) => {
if (this.user.purchased.background[bg.key]) {
ownedBackgrounds.push(bg);
}
});
});
return ownedBackgrounds;
},
},
methods: {
purchase (type, key) {
this.$store.dispatch('shops:purchase', {
type,
key,
});
this.backgroundUpdate = new Date();
},
prev () {
this.modalPage -= 1;
},
next () {
this.modalPage += 1;
},
changeTopPage (page, subpage) {
this.activeTopPage = page;
if (subpage) this.activeSubPage = subpage;
},
changeSubPage (page) {
this.activeSubPage = page;
},
async done () {
this.loading = true;
let tasksToCreate = [
...content.tasksByCategory.defaults.map(t => ({
...t,
text: t.text(),
notes: t.notes && t.notes(),
})),
];
this.taskCategories.forEach(category => {
tasksToCreate = tasksToCreate.concat(content.tasksByCategory[category].map(t => ({
...t,
text: t.text(),
notes: t.notes && t.notes(),
})));
});
// @TODO: Move to the action
let response = await axios.post('/api/v4/tasks/user', tasksToCreate);
let tasks = response.data.data;
tasks.forEach(task => {
this.$store.state.user.data.tasksOrder[`${task.type}s`].unshift(task._id);
this.$store.state.tasks.data[`${task.type}s`].unshift(task);
});
this.$root.$emit('bv::hide::modal', 'avatar-modal');
this.$router.push('/');
this.$store.dispatch('user:set', {
'flags.welcomed': true,
});
// @TODO: This is a timeout to ensure dom is loaded
window.setTimeout(() => {
this.initTour();
this.goto('intro', 0);
}, 1000);
},
showPlainBackgroundBlurb (identifier, set) {
return identifier === 'incentiveBackgrounds' && !this.ownsSet('background', set);
},
ownsSet (type, set) {
let setOwnedByUser = false;
for (let key in set) {
let value = set[key];
if (type === 'background') key = value.key;
if (this.user.purchased[type][key]) setOwnedByUser = true;
}
return setOwnedByUser;
},
setKeys (type, _set) {
return map(_set, (v, k) => {
if (type === 'background') k = v.key;
return `${type}.${k}`;
}).join(',');
},
backgroundLockedStatus (bgKey) {
let backgroundClass = 'background-locked';
if (this.user.purchased.background[bgKey]) backgroundClass = 'background-unlocked';
return backgroundClass;
},
isBackgroundPinned (bg) {
return isPinned(this.user, bg);
},
togglePinned (bg) {
if (!this.$store.dispatch('user:togglePinnedItem', {type: bg.pinType, path: bg.path})) {
this.text(this.$t('unpinnedItem', {item: bg.text}));
}
},
backgroundSelected (bg) {
this.$root.$emit('buyModal::showItem', bg);
},
backgroundPurchased () {
this.backgroundUpdate = new Date();
},
},
};
</script>
@@ -0,0 +1,4 @@
<template lang="pug">
// Container component used when you only need an empty view to support children routes
router-view
</template>
@@ -0,0 +1,93 @@
<template lang="pug">
.row.standard-page(v-if='groupIsSubscribed && isLeader')
.col-12.col-md-6.offset-md-3
table.table.alert.alert-info
tr(v-if='group.purchased.plan.dateTerminated')
td.alert.alert-warning
span.noninteractive-button.btn-danger {{ $t('canceledGroupPlan') }}
i.glyphicon.glyphicon-time {{ $t('groupPlanCanceled') }}
strong {{dateTerminated}}
tr(v-if='!group.purchased.plan.dateTerminated')
td
h3 {{ $t('paymentDetails') }}
p(v-if='group.purchased.plan.planId') {{ $t('groupSubscriptionPrice') }}
tr(v-if='group.purchased.plan.extraMonths')
td
span.glyphicon.glyphicon-credit-card
| {{ $t('purchasedGroupPlanPlanExtraMonths', purchasedGroupPlanPlanExtraMonths) }}
tr(v-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset')
td
span.glyphicon.glyphicon-forward
| {{ $t('consecutiveSubscription') }}
ul.list-unstyled
li {{ $t('consecutiveMonths') }} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}}
li {{ $t('gemCapExtra') }} {{group.purchased.plan.consecutive.gemCapExtra}}
li {{ $t('mysticHourglasses') }} {{group.purchased.plan.consecutive.trinkets}}
.col-12.col-md-6.offset-md-3
button.btn.btn-success(class='btn-success', v-if='group.purchased.plan.dateTerminated', @click='upgradeGroup()')
| {{ $t('upgrade') }}
.btn.btn-primary(v-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod === "Stripe"',
@click='showStripeEdit({groupId: group.id})') {{ $t('subUpdateCard') }}
.btn.btn-sm.btn-danger(v-if='!group.purchased.plan.dateTerminated',
@click='cancelSubscriptionConfirm({group: group})') {{ $t('cancelGroupSub') }}
</template>
<script>
import moment from 'moment';
import { mapState } from 'client/libs/store';
import paymentsMixin from 'client/mixins/payments';
import { CONSTANTS, getLocalSetting, removeLocalSetting } from 'client/libs/userlocalManager';
export default {
mixins: [paymentsMixin],
props: ['groupId'],
data () {
return {
group: {},
};
},
async mounted () {
await this.loadGroup();
let appState = getLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
if (appState) {
appState = JSON.parse(appState);
if (appState.groupPlanCanceled) {
removeLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
this.$root.$emit('habitica:subscription-canceled', {
dateTerminated: this.dateTerminated,
isGroup: true,
});
}
}
},
computed: {
...mapState({user: 'user.data'}),
isLeader () {
return this.user._id === this.group.leader._id;
},
groupIsSubscribed () {
return this.group.purchased && this.group.purchased.plan && this.group.purchased.plan.customerId;
},
dateTerminated () {
if (!this.user.preferences || !this.user.preferences.dateFormat) return moment(this.group.purchased.plan.dateTerminated);
return moment(this.group.purchased.plan.dateTerminated).format(this.user.preferences.dateFormat.toUpperCase());
},
purchasedGroupPlanPlanExtraMonths () {
return {
months: parseFloat(this.group.purchased.plan.extraMonths).toFixed(2),
};
},
},
methods: {
async loadGroup () {
let group = await this.$store.dispatch('guilds:getGroup', {groupId: this.groupId});
this.group = Object.assign({}, group);
},
upgradeGroup () {
this.$store.state.upgradingGroup = this.group;
this.$router.push('/group-plans');
},
},
};
</script>
@@ -0,0 +1,156 @@
<template lang="pug">
.create-group-modal-pages
.col-12(v-if='activePage === PAGES.CREATE_GROUP')
h2 {{ $t('nameYourGroup') }}
.form-group
label.control-label(for='new-group-name') {{ $t('name') }}
input.form-control#new-group-name.input-medium.option-content(required, type='text', :placeholder="$t('exampleGroupName')", v-model='newGroup.name')
.form-group
label(for='new-group-description') {{ $t('description') }}
textarea.form-control#new-group-description.option-content(cols='3', :placeholder="$t('exampleGroupDesc')", v-model='newGroup.description')
.form-group.text-left(v-if='newGroup.type === "guild"')
.custom-control.custom-radio
input.custom-control-input(type='radio', name='new-group-privacy', value='private', v-model='newGroup.privacy')
label.custom-control-label {{ $t('thisGroupInviteOnly') }}
.form-group.text-left
.custom-control.custom-checkbox
input.custom-control-input(type='checkbox', v-model='newGroup.leaderOnly.challenges' id='create-group-leaderOnlyChallenges-checkbox')
label.custom-control-label(for='create-group-leaderOnlyChallenges-checkbox') {{ $t('leaderOnlyChallenges') }}
.form-group(v-if='newGroup.type === "party"')
button.btn.btn-secondary.form-control(@click='createGroup()', :value="$t('createGroupPlan')")
.form-group
button.btn.btn-primary.btn-lg.btn-block(@click="createGroup()", :disabled="!newGroupIsReady") {{ $t('createGroupPlan') }}
.col-12(v-if='activePage === PAGES.PAY')
h2 {{ $t('choosePaymentMethod') }}
.payments-column
button.purchase.btn.btn-primary.payment-button.payment-item(@click='pay(PAYMENTS.STRIPE)')
.svg-icon.credit-card-icon(v-html="icons.creditCardIcon")
| {{ $t('card') }}
amazon-button.payment-item(:amazon-data="pay(PAYMENTS.AMAZON)")
</template>
<style lang="scss" scoped>
h2 {
font-family: 'Varela Round', sans-serif;
font-weight: normal;
font-size: 29px;
color: #34313a;
margin-top: 1em;
}
.box {
border-radius: 2px;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: 2em;
text-align: center;
vertical-align: bottom;
height: 100px;
width: 306px;
margin: 0 auto;
margin-bottom: 1em;
}
.box .svg-icon {
margin: 0 auto;
}
.form-group {
text-align: left;
font-weight: bold;
}
.box:hover {
cursor: pointer;
opacity: 0.7;
}
.btn-block {
margin-bottom: 1em;
}
</style>
<script>
import * as Analytics from 'client/libs/analytics';
import { mapState } from 'client/libs/store';
import paymentsMixin from '../../mixins/payments';
import amazonButton from 'client/components/payments/amazonButton';
import creditCardIcon from 'assets/svg/credit-card-icon.svg';
export default {
mixins: [paymentsMixin],
components: {
amazonButton,
},
data () {
return {
amazonPayments: {},
icons: Object.freeze({
creditCardIcon,
}),
PAGES: {
CREATE_GROUP: 'create-group',
UPGRADE_GROUP: 'upgrade-group',
PAY: 'pay',
},
PAYMENTS: {
AMAZON: 'amazon',
STRIPE: 'stripe',
},
activePage: 'create-group',
newGroup: {
type: 'guild',
privacy: 'private',
name: '',
leaderOnly: {
challenges: false,
},
},
};
},
computed: {
...mapState({user: 'user.data'}),
newGroupIsReady () {
return Boolean(this.newGroup.name);
},
},
methods: {
changePage (page) {
Analytics.track({
hitType: 'event',
eventCategory: 'group-plans-static',
eventAction: 'view',
eventLabel: page,
});
this.activePage = page;
window.scrollTo(0, 0);
},
createGroup () {
this.changePage(this.PAGES.PAY);
},
pay (paymentMethod) {
const subscriptionKey = 'group_monthly';
let paymentData = {
subscription: subscriptionKey,
coupon: null,
};
if (this.upgradingGroup && this.upgradingGroup._id) {
paymentData.groupId = this.upgradingGroup._id;
paymentData.group = this.upgradingGroup;
} else {
paymentData.groupToCreate = this.newGroup;
}
this.paymentMethod = paymentMethod;
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
this.showStripe(paymentData);
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
paymentData.type = 'subscription';
return paymentData;
}
},
},
};
</script>
@@ -0,0 +1,235 @@
<template lang="pug">
b-modal#group-plan-overview(title="Empty", size='lg', hide-footer=true, @shown='shown()')
.header-wrap.text-center(slot="modal-header")
h2(v-once) {{ $t('gettingStarted') }}
p(v-once) {{ $t('congratsOnGroupPlan') }}
.row
.col-12
.card(:class='{expanded: expandedQuestions.question1}')
.question-head
.q Q.
.title {{ $t('whatsIncludedGroup') }}
.arrow.float-right(@click='toggle("question1")')
.svg-icon(v-html="icons.upIcon", v-if='expandedQuestions.question1')
.svg-icon(v-html="icons.downIcon", v-else)
.question-body(v-if='expandedQuestions.question1')
p {{ $t('whatsIncludedGroupDesc') }}
.col-12
.card(:class='{expanded: expandedQuestions.question2}')
.question-head
.q Q.
.title {{ $t('howDoesBillingWork') }}
.arrow.float-right(@click='toggle("question2")')
.svg-icon(v-html="icons.upIcon", v-if='expandedQuestions.question2')
.svg-icon(v-html="icons.downIcon", v-else)
.question-body(v-if='expandedQuestions.question2')
p {{ $t('howDoesBillingWorkDesc') }}
.col-12
.card(:class='{expanded: expandedQuestions.question3}')
.question-head
.q Q.
.title {{ $t('howToAssignTask') }}
.arrow.float-right(@click='toggle("question3")')
.svg-icon(v-html="icons.upIcon", v-if='expandedQuestions.question3')
.svg-icon(v-html="icons.downIcon", v-else)
.question-body(v-if='expandedQuestions.question3')
p {{ $t('howToAssignTaskDesc') }}
.assign-tasks.image-example
.col-12
.card(:class='{expanded: expandedQuestions.question4}')
.question-head
.q Q.
.title {{ $t('howToRequireApproval') }}
.arrow.float-right(@click='toggle("question4")')
.svg-icon(v-html="icons.upIcon", v-if='expandedQuestions.question4')
.svg-icon(v-html="icons.downIcon", v-else)
.question-body(v-if='expandedQuestions.question4')
p {{ $t('howToRequireApprovalDesc') }}
.requires-approval.image-example
p {{ $t('howToRequireApprovalDesc2') }}
.approval-requested.image-example
.col-12
.card(:class='{expanded: expandedQuestions.question5}')
.question-head
.q Q.
.title {{ $t('whatIsGroupManager') }}
.arrow.float-right(@click='toggle("question5")')
.svg-icon(v-html="icons.upIcon", v-if='expandedQuestions.question5')
.svg-icon(v-html="icons.downIcon", v-else)
.question-body(v-if='expandedQuestions.question5')
p {{ $t('whatIsGroupManagerDesc') }}
.promote-leader.image-example
.col-12.text-center
button.btn.btn-primary.close-button(@click='close()') {{ $t('goToTaskBoard') }}
</template>
<style>
#group-plan-overview___BV_modal_header_ {
border-bottom: none;
}
</style>
<style lang="scss" scoped>
@import url('https://fonts.googleapis.com/css?family=Varela+Round');
.header-wrap {
padding-left: 4em;
padding-right: 4em;
h2 {
font-size: 32px;
font-weight: bold;
margin-top: 1em;
}
p {
color: #878190;
font-size: 16px;
}
}
.row {
margin-bottom: 2em;
.col-12 {
margin-bottom: .5em;
}
}
.card.expanded {
padding-bottom: 1em;
.title {
color: #4f2a93;
}
}
.card {
min-height: 60px;
border-radius: 4px;
background-color: #ffffff;
border: none;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
.question-head {
.q {
font-family: 'Varela Round', sans-serif;
font-size: 20px;
color: #a5a1ac;
margin: 1em;
}
.title {
font-weight: normal;
}
div {
display: inline-block;
}
.arrow {
margin: 1em;
padding-top: .9em;
.svg-icon {
width: 26px;
height: 16px;
}
}
.arrow:hover {
cursor: pointer;
}
}
.question-body {
padding-left: 4.4em;
padding-right: 4em;
p {
color: #4e4a57;
}
}
}
.image-example {
background-repeat: no-repeat;
margin: 0 auto;
background-position: center;
background-size: contain;
}
.assign-tasks {
background-image: url('~assets/images/group-plans/assign-task@3x.png');
width: 400px;
height: 150px;
}
.requires-approval {
background-image: url('~assets/images/group-plans/requires-approval@3x.png');
width: 402px;
height: 20px;
margin-bottom: 1em;
}
.approval-requested {
background-image: url('~assets/images/group-plans/approval-requested@3x.png');
width: 471px;
height: 204px;
}
.promote-leader {
background-image: url('~assets/images/group-plans/promote-leader@3x.png');
width: 423px;
height: 185px;
}
.close-button {
margin-top: 1em;
}
</style>
<script>
import * as Analytics from 'client/libs/analytics';
import { mapState } from 'client/libs/store';
import upIcon from 'assets/svg/up.svg';
import downIcon from 'assets/svg/down.svg';
export default {
data () {
return {
icons: Object.freeze({
upIcon,
downIcon,
}),
expandedQuestions: {
question1: false,
question2: false,
question3: false,
question4: false,
question5: false,
},
};
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
shown () {
Analytics.track({
hitType: 'event',
eventCategory: 'button',
eventAction: 'click',
eventLabel: 'viewed-group-plan-overview',
});
},
toggle (question) {
this.expandedQuestions[question] = !this.expandedQuestions[question];
},
close () {
this.$root.$emit('bv::hide::modal', 'group-plan-overview');
},
},
};
</script>
@@ -0,0 +1,48 @@
<template lang="pug">
.row
group-form-modal
secondary-menu.col-12
router-link.nav-link(:to="{name: 'groupPlanDetailTaskInformation', params: {groupId}}",
exact, :class="{'active': $route.name === 'groupPlanDetailTaskInformation'}") {{ $t('groupTaskBoard') }}
router-link.nav-link(:to="{name: 'groupPlanDetailInformation', params: {groupId}}",
exact, :class="{'active': $route.name === 'groupPlanDetailInformation'}") {{ $t('groupInformation') }}
router-link.nav-link(
v-if='isLeader',
:to="{name: 'groupPlanBilling', params: {groupId}}",
exact,
:class="{'active': $route.name === 'groupPlanBilling'}") {{ $t('groupBilling') }}
.col-12
router-view
</template>
<script>
import groupFormModal from 'client/components/groups/groupFormModal';
import SecondaryMenu from 'client/components/secondaryMenu';
import { mapState } from 'client/libs/store';
export default {
props: ['groupId'],
components: {
SecondaryMenu,
groupFormModal,
},
computed: {
...mapState({
user: 'user.data',
groupPlans: 'groupPlans',
}),
currentGroup () {
let groupFound = this.groupPlans.find(group => {
return group._id === this.groupId;
});
return groupFound;
},
isLeader () {
if (!this.currentGroup) return false;
return this.currentGroup.leader === this.user._id;
},
},
};
</script>
@@ -0,0 +1,312 @@
<template lang="pug">
.standard-page
group-plan-overview-modal
task-modal(
:task="workingTask",
:purpose="taskFormPurpose",
@cancel="cancelTaskModal()",
ref="taskModal",
:groupId="groupId",
v-on:taskCreated='taskCreated',
v-on:taskEdited='taskEdited',
v-on:taskDestroyed='taskDestroyed'
)
.row.tasks-navigation
.col-12.col-md-4
h1 {{ $t('groupTasksTitle') }}
// @TODO: Abstract to component!
.col-12.col-md-4
input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText")
.create-task-area.d-flex(v-if='canCreateTasks')
transition(name="slide-tasks-btns")
.d-flex(v-if="openCreateBtn")
.create-task-btn.diamond-btn(
v-for="type in columns",
:key="type",
@click="createTask(type)",
v-b-tooltip.hover.bottom="$t(type)",
)
.svg-icon(v-html="icons[type]", :class='`icon-${type}`')
#create-task-btn.create-btn.diamond-btn.btn.btn-success(
@click="openCreateBtn = !openCreateBtn",
:class="{open: openCreateBtn}",
)
.svg-icon(v-html="icons.positive")
b-tooltip(target="create-task-btn", placement="bottom", v-if="!openCreateBtn") {{ $t('addTaskToGroupPlan') }}
.row
task-column.col-12.col-md-3(
v-for="column in columns",
:type="column",
:key="column",
:taskListOverride='tasksByType[column]',
:showOptions="showOptions"
v-on:editTask="editTask",
v-on:loadGroupCompletedTodos="loadGroupCompletedTodos",
v-on:taskDestroyed="taskDestroyed",
:group='group',
:searchText="searchText")
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/create-task.scss';
.tasks-navigation {
margin-bottom: 40px;
}
.positive {
display: inline-block;
width: 10px;
color: $green-500;
margin-right: 8px;
padding-top: 6px;
}
</style>
<script>
import taskDefaults from 'common/script/libs/taskDefaults';
import TaskColumn from '../tasks/column';
import TaskModal from '../tasks/taskModal';
import GroupPlanOverviewModal from './groupPlanOverviewModal';
import positiveIcon from 'assets/svg/positive.svg';
import filterIcon from 'assets/svg/filter.svg';
import deleteIcon from 'assets/svg/delete.svg';
import habitIcon from 'assets/svg/habit.svg';
import dailyIcon from 'assets/svg/daily.svg';
import todoIcon from 'assets/svg/todo.svg';
import rewardIcon from 'assets/svg/reward.svg';
import Vue from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import findIndex from 'lodash/findIndex';
import groupBy from 'lodash/groupBy';
import { mapState } from 'client/libs/store';
export default {
props: ['groupId'],
components: {
TaskColumn,
TaskModal,
GroupPlanOverviewModal,
},
data () {
return {
openCreateBtn: false,
searchId: '',
columns: ['habit', 'daily', 'todo', 'reward'],
tasksByType: {
habit: [],
daily: [],
todo: [],
reward: [],
},
editingTask: {},
creatingTask: {},
workingTask: {},
taskFormPurpose: 'create',
// @TODO: Separate component?
searchText: '',
selectedTags: [],
temporarilySelectedTags: [],
isFilterPanelOpen: false,
icons: Object.freeze({
positive: positiveIcon,
filter: filterIcon,
destroy: deleteIcon,
habit: habitIcon,
daily: dailyIcon,
todo: todoIcon,
reward: rewardIcon,
}),
editingTags: false,
group: {},
};
},
watch: {
// call again the method if the route changes (when this route is already active)
$route: 'load',
},
beforeRouteUpdate (to, from, next) {
this.$set(this, 'searchId', to.params.groupId);
next();
},
mounted () {
if (!this.searchId) this.searchId = this.groupId;
this.load();
if (this.$route.query.showGroupOverview) {
this.$root.$emit('bv::show::modal', 'group-plan-overview');
}
},
computed: {
...mapState({user: 'user.data'}),
tagsByType () {
const userTags = this.user.tags;
const tagsByType = {
challenges: {
key: 'challenges',
tags: [],
},
groups: {
key: 'groups',
tags: [],
},
user: {
key: 'tags',
tags: [],
},
};
userTags.forEach(t => {
if (t.group) {
tagsByType.groups.tags.push(t);
} else if (t.challenge) {
tagsByType.challenges.tags.push(t);
} else {
tagsByType.user.tags.push(t);
}
});
return tagsByType;
},
canCreateTasks () {
if (!this.group) return false;
return this.group.leader && this.group.leader._id === this.user._id || this.group.managers && Boolean(this.group.managers[this.user._id]);
},
showOptions () {
return this.canCreateTasks;
},
},
methods: {
async load () {
this.tasksByType = {
habit: [],
daily: [],
todo: [],
reward: [],
};
this.group = await this.$store.dispatch('guilds:getGroup', {
groupId: this.searchId,
});
let members = await this.$store.dispatch('members:getGroupMembers', {groupId: this.searchId});
this.group.members = members;
let tasks = await this.$store.dispatch('tasks:getGroupTasks', {
groupId: this.searchId,
});
let groupedApprovals = await this.loadApprovals();
tasks.forEach((task) => {
if (groupedApprovals[task._id] && groupedApprovals[task._id].length > 0) task.approvals = groupedApprovals[task._id];
this.tasksByType[task.type].push(task);
});
},
async loadApprovals () {
let approvalRequests = await this.$store.dispatch('tasks:getGroupApprovals', {
groupId: this.searchId,
});
return groupBy(approvalRequests, 'group.taskId');
},
editTask (task) {
this.taskFormPurpose = 'edit';
this.editingTask = cloneDeep(task);
this.workingTask = this.editingTask;
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
async loadGroupCompletedTodos () {
const completedTodos = await this.$store.dispatch('tasks:getCompletedGroupTasks', {
groupId: this.searchId,
});
completedTodos.forEach((task) => {
const existingTaskIndex = findIndex(this.tasksByType.todo, (todo) => {
return todo._id === task._id;
});
if (existingTaskIndex === -1) {
this.tasksByType.todo.push(task);
}
});
},
createTask (type) {
this.openCreateBtn = false;
this.taskFormPurpose = 'create';
this.creatingTask = taskDefaults({type, text: ''}, this.user);
this.workingTask = this.creatingTask;
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
taskCreated (task) {
task.group.id = this.group._id;
this.tasksByType[task.type].push(task);
},
taskEdited (task) {
let index = findIndex(this.tasksByType[task.type], (taskItem) => {
return taskItem._id === task._id;
});
this.tasksByType[task.type].splice(index, 1, task);
},
taskDestroyed (task) {
let index = findIndex(this.tasksByType[task.type], (taskItem) => {
return taskItem._id === task._id;
});
this.tasksByType[task.type].splice(index, 1);
},
cancelTaskModal () {
this.editingTask = null;
this.creatingTask = null;
this.workingTask = {};
},
toggleFilterPanel () {
if (this.isFilterPanelOpen === true) {
this.closeFilterPanel();
} else {
this.openFilterPanel();
}
},
openFilterPanel () {
this.isFilterPanelOpen = true;
this.temporarilySelectedTags = this.selectedTags.slice();
},
closeFilterPanel () {
this.temporarilySelectedTags = [];
this.isFilterPanelOpen = false;
},
resetFilters () {
this.selectedTags = [];
this.closeFilterPanel();
},
applyFilters () {
const temporarilySelectedTags = this.temporarilySelectedTags;
this.selectedTags = temporarilySelectedTags.slice();
this.closeFilterPanel();
},
toggleTag (tag) {
const temporarilySelectedTags = this.temporarilySelectedTags;
const tagI = temporarilySelectedTags.indexOf(tag.id);
if (tagI === -1) {
temporarilySelectedTags.push(tag.id);
} else {
temporarilySelectedTags.splice(tagI, 1);
}
},
isTagSelected (tag) {
const tagId = tag.id;
if (this.temporarilySelectedTags.indexOf(tagId) !== -1) return true;
return false;
},
},
};
</script>
@@ -0,0 +1,25 @@
<template lang="pug">
b-link(
v-if='group',
@click.prevent='goToGroup'
) {{group.name}}
</template>
<script>
import { TAVERN_ID } from '../../common/script/constants';
export default {
props: ['group'],
methods: {
goToGroup () {
if (this.group.type === 'party') {
this.$router.push({name: 'party'});
} else if (this.group._id === TAVERN_ID) {
this.$router.push({name: 'tavern'});
} else {
this.$router.push({name: 'guild', params: {groupId: this.group._id}});
}
},
},
};
</script>
@@ -0,0 +1,313 @@
<template lang="pug">
.row.chat-row
.col-12
h3.float-left.label(:class="{accepted: communityGuidelinesAccepted }") {{ label }}
div.float-right(v-markdown='$t("markdownFormattingHelp")')
.row(v-if="communityGuidelinesAccepted")
textarea(:placeholder='placeholder',
v-model='newMessage',
ref='user-entry',
:class='{"user-entry": newMessage}',
@keydown='updateCarretPosition',
@keyup.ctrl.enter='sendMessageShortcut()',
@keydown.tab='handleTab($event)',
@keydown.up='selectPreviousAutocomplete($event)',
@keydown.down='selectNextAutocomplete($event)',
@keydown.enter='selectAutocomplete($event)',
@keydown.esc='handleEscape($event)',
@paste='disableMessageSendShortcut()',
maxlength='3000'
)
span {{ currentLength }} / 3000
autocomplete(
ref='autocomplete',
:text='newMessage',
v-on:select="selectedAutocomplete",
:textbox='textbox',
:coords='coords',
:caretPosition = 'caretPosition',
:chat='group.chat')
community-guidelines
.row.chat-actions
.col-6.chat-receive-actions
button.btn.btn-secondary.float-left.fetch(v-once, @click='fetchRecentMessages()') {{ $t('fetchRecentMessages') }}
button.btn.btn-secondary.float-left(v-once, @click='reverseChat()') {{ $t('reverseChat') }}
.col-6.chat-send-actions
button.btn.btn-primary.send-chat.float-right(:disabled="!communityGuidelinesAccepted", @click='sendMessage()') {{ $t('send') }}
slot(
name="additionRow",
)
.row
.hr.col-12
chat-message(:chat.sync='group.chat', :group-type='group.type', :group-id='group._id', :group-name='group.name')
</template>
<script>
import debounce from 'lodash/debounce';
import autocomplete from '../chat/autoComplete';
import communityGuidelines from './communityGuidelines';
import chatMessage from '../chat/chatMessages';
import { mapState } from 'client/libs/store';
import markdownDirective from 'client/directives/markdown';
export default {
props: ['label', 'group', 'placeholder'],
directives: {
markdown: markdownDirective,
},
components: {
autocomplete,
communityGuidelines,
chatMessage,
},
data () {
return {
newMessage: '',
sending: false,
caretPosition: 0,
chat: {
submitDisable: false,
submitTimeout: null,
},
coords: {
TOP: 0,
LEFT: 0,
},
textbox: this.$refs,
};
},
computed: {
...mapState({user: 'user.data'}),
currentLength () {
return this.newMessage.length;
},
communityGuidelinesAccepted () {
return this.user.flags.communityGuidelinesAccepted;
},
},
methods: {
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
getCoord (e, text) {
this.caretPosition = text.selectionEnd;
let div = document.createElement('div');
let span = document.createElement('span');
let copyStyle = getComputedStyle(text);
[].forEach.call(copyStyle, (prop) => {
div.style[prop] = copyStyle[prop];
});
div.style.position = 'absolute';
document.body.appendChild(div);
div.textContent = text.value.substr(0, this.caretPosition);
span.textContent = text.value.substr(this.caretPosition) || '.';
div.appendChild(span);
this.coords = {
TOP: span.offsetTop,
LEFT: span.offsetLeft,
};
document.body.removeChild(div);
},
updateCarretPosition: debounce(function updateCarretPosition (eventUpdate) {
this._updateCarretPosition(eventUpdate);
}, 250),
_updateCarretPosition (eventUpdate) {
let text = eventUpdate.target;
this.getCoord(eventUpdate, text);
},
async sendMessageShortcut () {
// If the user recently pasted in the text field, don't submit
if (!this.chat.submitDisable) {
this.sendMessage();
}
},
async sendMessage () {
if (this.sending) return;
this.sending = true;
let response;
try {
response = await this.$store.dispatch('chat:postChat', {
group: this.group,
message: this.newMessage,
});
} catch (e) {
// catch exception to allow function to continue
}
if (response) {
this.group.chat.unshift(response.message);
this.newMessage = '';
}
this.sending = false;
// @TODO: I would like to not reload everytime we send. Why are we reloading?
// The response has all the necessary data...
let chat = await this.$store.dispatch('chat:getChat', {groupId: this.group._id});
this.group.chat = chat;
},
disableMessageSendShortcut () {
// Some users were experiencing accidental sending of messages after pasting
// So, after pasting, disable the shortcut for a second.
this.chat.submitDisable = true;
if (this.chat.submitTimeout) {
// If someone pastes during the disabled period, prevent early re-enable
clearTimeout(this.chat.submitTimeout);
this.chat.submitTimeout = null;
}
this.chat.submitTimeout = window.setTimeout(() => {
this.chat.submitTimeout = null;
this.chat.submitDisable = false;
}, 500);
},
handleTab (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
if (e.shiftKey) {
this.$refs.autocomplete.selectPrevious();
} else {
this.$refs.autocomplete.selectNext();
}
}
},
handleEscape (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.cancel();
}
},
selectNextAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectNext();
}
},
selectPreviousAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.selectPrevious();
}
},
selectAutocomplete (e) {
if (this.$refs.autocomplete.searchActive) {
e.preventDefault();
this.$refs.autocomplete.makeSelection();
}
},
selectedAutocomplete (newText) {
this.newMessage = newText;
},
fetchRecentMessages () {
this.$emit('fetchRecentMessages');
},
reverseChat () {
this.group.chat.reverse();
},
},
beforeRouteUpdate (to, from, next) {
// Reset chat
this.newMessage = '';
this.coords = {
TOP: 0,
LEFT: 0,
};
next();
},
};
</script>
<style scoped lang="scss">
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/variables.scss';
.chat-actions {
margin-top: 1em;
.chat-receive-actions {
padding-left: 0;
button {
margin-bottom: 1em;
&:not(:last-child) {
margin-right: 1em;
}
}
}
.chat-send-actions {
padding-right: 0;
}
}
.chat-row {
position: relative;
.label:not(.accepted) {
color: #a5a1ac;
}
.row {
margin-left: 0;
margin-right: 0;
clear: both;
}
textarea {
min-height: 150px;
width: 100%;
background-color: $white;
border: solid 1px $gray-400;
font-style: italic;
line-height: 1.43;
color: $gray-300;
padding: 10px 12px;
}
.user-entry {
font-style: normal;
color: $black;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.hr-middle {
font-size: 16px;
font-weight: bold;
font-family: 'Roboto Condensed';
line-height: 1.5;
text-align: center;
color: $gray-200;
background-color: $gray-700;
padding: .2em;
margin-top: .2em;
display: inline-block;
width: 100px;
}
}
</style>
@@ -0,0 +1,61 @@
<template lang="pug">
.row.community-guidelines(v-if='!communityGuidelinesAccepted')
div.col.col-sm-12.col-xl-8(v-once, v-html="$t('communityGuidelinesIntro')")
div.col-md-auto.col-md-12.col-xl-4
button.btn.btn-info.btn-follow-guidelines(@click='acceptCommunityGuidelines()', v-once) {{ $t('acceptCommunityGuidelines') }}
</template>
<style lang="scss">
@import '~client/assets/scss/colors.scss';
.community-guidelines {
background-color: rgba(135, 129, 144, 0.84);
padding-left: 0;
padding-right: 0;
color: $white;
width: 100%;
// width: calc(100% - 24px);
border-radius: 4px;
align-items: center;
justify-content: center;
.col {
padding: 20px 24px;
}
a {
text-decoration: underline;
color: $white;
font-weight: bold;
}
button {
margin: 20px 12px;
padding-top: 5px;
padding-bottom: 5px;
}
.btn-follow-guidelines {
white-space: pre-line;
}
}
</style>
<script>
import { mapState } from 'client/libs/store';
export default {
computed: {
...mapState({user: 'user.data'}),
communityGuidelinesAccepted () {
return this.user.flags.communityGuidelinesAccepted;
},
},
methods: {
acceptCommunityGuidelines () {
this.$store.dispatch('user:set', {'flags.communityGuidelinesAccepted': true});
},
},
};
</script>
@@ -0,0 +1,194 @@
<template lang="pug">
b-modal#create-party-modal(size='lg', hide-footer=true)
.header-wrap(slot="modal-header")
.quest_screen
.row.heading
.col-12.text-center.pr-5.pl-5
h2(v-once) {{ $t('playInPartyTitle') }}
p.mb-4(v-once) {{ $t('playInPartyDescription') }}
button.btn.btn-primary(v-once, @click='createParty()') {{ $t('createParty') }}
.row.grey-row
.col-12.text-center
.join-party
h2(v-once) {{ $t('wantToJoinPartyTitle') }}
p(v-html='$t("wantToJoinPartyDescription")')
.form-group(@click='copyUsername')
.d-flex.align-items-center
label.mr-3(v-once) {{ $t('username') }}
.flex-grow-1
.input-group-prepend.input-group-text @
.text {{ user.auth.local.username }}
.svg-icon.copy-icon(v-html='icons.copy')
.small(v-once) {{ $t('copy') }}
</template>
<style>
#create-party-modal .modal-body {
padding: 0rem 0.75rem;
}
#create-party-modal .modal-dialog {
width: 35.75rem;
}
#create-party-modal .modal-header {
padding: 0;
border-bottom: 0px;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.copy-icon {
width: 1rem;
}
.form-control[readonly] {
background-color: $white;
color: $gray-50;
}
.form-group {
background-color: $gray-600;
border-radius: 2px;
border: solid 1px $gray-500;
width: 90%;
margin: auto;
cursor: pointer;
}
.grey-row {
background-color: $gray-700;
color: #4e4a57;
padding: 2em;
border-radius: 0px 0px 2px 2px;
}
h2 {
color: $gray-100;
}
.header-wrap {
padding: 0;
color: #4e4a57;
width: 100%;
.quest_screen {
background-image: url('~client/assets/images/group@3x.png');
background-size: cover;
width: 100%;
height: 246px;
margin-bottom: 1.5rem;
border-radius: 2px 2px 0 0;
image-rendering: optimizequality;
}
h2 {
color: $purple-200;
}
}
.heading {
margin-top: 1rem;
margin-bottom: 1rem;
}
input.form-control {
border: 0px;
padding-left: 0.25rem;
}
.input-group-prepend {
margin-right: 0px;
}
.input-group-text {
background-color: $white;
border: 0px;
border-radius: 0px;
color: $gray-300;
padding: 0rem 0.1rem 0rem 0.75rem;
}
.join-party {
background-image: url('~client/assets/images/party.png');
background-size: cover;
width: 203px;
height: 66px;
margin: 0 auto;
margin-bottom: 1em;
}
label {
color: $gray-100;
font-weight: bold;
margin-bottom: 0rem;
margin-left: 1rem;
cursor: pointer;
}
.modal-dialog .text {
min-height: 1rem;
margin: 0.75rem auto 0.75rem 0.25rem;
}
.small {
color: $gray-200;
margin: auto 0.5rem auto 0.25rem;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import notifications from 'client/mixins/notifications';
import copyIcon from 'assets/svg/copy.svg';
export default {
data () {
return {
icons: Object.freeze({
copy: copyIcon,
}),
};
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
async createParty () {
let group = {
type: 'party',
};
group.name = this.$t('possessiveParty', {name: this.user.profile.name});
let party = await this.$store.dispatch('guilds:create', {group});
this.$store.state.party.data = party;
this.user.party._id = party._id;
Analytics.updateUser({
partyID: party._id,
partySize: 1,
});
this.$root.$emit('bv::hide::modal', 'create-party-modal');
this.$router.push('/party');
},
copyUsername () {
if (navigator.clipboard) {
navigator.clipboard.writeText(this.user.auth.local.username);
} else {
let copyText = document.createElement('textarea');
copyText.value = this.user.auth.local.username;
document.body.appendChild(copyText);
copyText.select();
document.execCommand('copy');
document.body.removeChild(copyText);
}
this.text(this.$t('usernameCopied'));
},
},
mixins: [notifications],
};
</script>
@@ -0,0 +1,212 @@
<template lang="pug">
.row
sidebar(@search="updateSearch", @filter="updateFilters")
.standard-page
.header.row
.col-8
h1.page-header.float-left(v-once) {{ $t('publicGuilds') }}
.col-4
// @TODO: Add when we implement recent activity .float-right
span.dropdown-label {{ $t('sortBy') }}
b-dropdown(:text="$t('sort')", right=true)
b-dropdown-item(v-for='sortOption in sortOptions', :key="sortOption.value", @click='sort(sortOption.value)') {{sortOption.text}}
button.btn.btn-secondary.create-group-button.float-right(@click='createGroup()')
.svg-icon.positive-icon(v-html="icons.positiveIcon")
span(v-once) {{$t('createGuild2')}}
.row
.no-guilds.text-center.col-md-6.offset-md-3(v-if='!loading && filteredGuilds.length === 0')
h2(v-once) {{$t('noGuildsMatchFilters')}}
.row
.col-md-12
public-guild-item(v-for="guild in filteredGuilds", :key='guild._id', :guild="guild", :display-leave='true')
mugen-scroll(
:handler="fetchGuilds",
:should-handle="!loading && !this.hasLoadedAllGuilds",
:handle-on-mount="true",
v-show="loading",
)
span(v-once) {{ $t('loading') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.sort-select {
margin: 2em;
}
.positive-icon {
color: $green-10;
width: 10px;
display: inline-block;
margin-right: .5em;
}
.no-guilds {
color: $gray-200;
margin-top: 10em;
h2 {
color: $gray-200;
}
}
</style>
<script>
import MugenScroll from 'vue-mugen-scroll';
import PublicGuildItem from './publicGuildItem';
import Sidebar from './sidebar';
import groupUtilities from 'client/mixins/groupsUtilities';
import positiveIcon from 'assets/svg/positive.svg';
function _mapCategories (guilds) {
guilds.forEach((guild) => {
if (!guild.categories) return;
guild.categorySlugs = guild.categories.map(cat => {
if (!cat) return;
return cat.slug;
});
});
}
export default {
mixins: [groupUtilities],
components: { PublicGuildItem, MugenScroll, Sidebar },
data () {
return {
icons: Object.freeze({
positiveIcon,
}),
loading: false,
hasLoadedAllGuilds: false,
lastPageLoaded: 0,
search: '',
filters: {},
sort: 'none',
sortOptions: [
{
text: this.$t('none'),
value: 'none',
},
{
text: this.$t('memberCount'),
value: 'member_count',
},
{
text: this.$t('recentActivity'),
value: 'recent_activity',
},
],
guilds: [],
queryFilters: {
minMemberCount: 0,
maxMemberCount: 0,
leader: false,
member: false,
categories: '',
},
};
},
computed: {
filteredGuilds () {
let search = this.search;
let filters = this.filters;
let user = this.$store.state.user.data;
let filterGuild = this.filterGuild;
// @TODO: Move this to the server
return this.guilds.filter((guild) => {
return filterGuild(guild, filters, search, user);
});
},
},
methods: {
async updateSearch (eventData) {
// this.search = eventData.searchTerm; @TODO: Probably don't need this anymore
// Reset the page when filters are updated but not the other queries
this.lastPageLoaded = 0;
this.queryFilters.page = this.lastPageLoaded;
this.queryFilters.search = eventData.searchTerm;
let guilds = await this.$store.dispatch('guilds:getPublicGuilds', this.queryFilters);
_mapCategories(guilds);
this.guilds = guilds;
},
async updateFilters (eventData) {
// this.filters = eventData; @TODO: Probably don't need this anymore
// Reset all filters
this.queryFilters = {
minMemberCount: 0,
maxMemberCount: 0,
leader: false,
member: false,
categories: '',
};
// Reset the page when filters are updated
this.lastPageLoaded = 0;
this.hasLoadedAllGuilds = false;
this.queryFilters.page = this.lastPageLoaded;
this.queryFilters.categories = eventData.categories.join(',');
// Role filters
let filteringRole = eventData.roles && eventData.roles.length > 0;
if (filteringRole && eventData.roles.indexOf('member') !== -1) {
this.queryFilters.member = true;
}
if (filteringRole && eventData.roles.indexOf('guild_leader') !== -1) {
this.queryFilters.leader = true;
}
// Size filters
if (eventData.guildSize && eventData.guildSize.indexOf('gold_tier') !== -1) {
this.queryFilters.minMemberCount = 1000;
this.queryFilters.maxMemberCount = 0; // No max
}
if (eventData.guildSize && eventData.guildSize.indexOf('silver_tier') !== -1) {
this.queryFilters.minMemberCount = 100;
this.queryFilters.maxMemberCount = 999;
}
if (eventData.guildSize && eventData.guildSize.indexOf('bronze_tier') !== -1) {
this.queryFilters.minMemberCount = 0; // No Min
this.queryFilters.maxMemberCount = 99;
}
let guilds = await this.$store.dispatch('guilds:getPublicGuilds', this.queryFilters);
_mapCategories(guilds);
this.guilds = guilds;
},
async fetchGuilds () {
// We have the data cached
if (this.lastPageLoaded === 0 && this.guilds.length > 0) {
this.lastPageLoaded += 1;
}
this.loading = true;
this.queryFilters.page = this.lastPageLoaded;
let guilds = await this.$store.dispatch('guilds:getPublicGuilds', this.queryFilters);
if (guilds.length === 0) this.hasLoadedAllGuilds = true;
_mapCategories(guilds);
this.guilds.push(...guilds);
this.lastPageLoaded++;
this.loading = false;
},
createGroup () {
this.$store.state.editingGroup = {};
this.$root.$emit('bv::show::modal', 'guild-form');
},
},
};
</script>
@@ -0,0 +1,529 @@
<template lang="pug">
.row(v-if="group._id")
group-form-modal(v-if='isParty')
start-quest-modal(:group='this.group')
quest-details-modal(:group='this.group')
participant-list-modal(:group='this.group')
group-gems-modal
.col-12.col-sm-8.standard-page
.row
.col-12.col-md-6.title-details
h1 {{group.name}}
div
span.mr-1.ml-0
strong(v-once) {{$t('groupLeader')}}:
user-link.mx-1(:user="group.leader")
.col-12.col-md-6
.row.icon-row
.col-4.offset-4(v-bind:class="{ 'offset-8': isParty }")
.item-with-icon(@click="showMemberModal()")
.svg-icon.shield(v-html="icons.goldGuildBadgeIcon", v-if='group.memberCount > 1000')
.svg-icon.shield(v-html="icons.silverGuildBadgeIcon", v-if='group.memberCount > 100 && group.memberCount < 999')
.svg-icon.shield(v-html="icons.bronzeGuildBadgeIcon", v-if='group.memberCount < 100')
span.number {{ group.memberCount | abbrNum }}
div.member-list.label(v-once) {{ $t('memberList') }}
.col-4(v-if='!isParty')
.item-with-icon(@click='showGroupGems()')
.svg-icon.gem(v-html="icons.gem")
span.number {{group.balance * 4}}
div.label(v-once) {{ $t('guildBank') }}
chat(
:label="$t('chat')",
:group="group",
:placeholder="!isParty ? $t('chatPlaceholder') : $t('partyChatPlaceholder')",
@fetchRecentMessages="fetchRecentMessages()"
)
template(slot="additionRow")
.row(v-if='showNoNotificationsMessage')
.col-12.no-notifications
| {{$t('groupNoNotifications')}}
.col-12.col-sm-4.sidebar
.row(:class='{"guild-background": !isParty}')
.col-12.buttons-wrapper
.button-container
button.btn.btn-success(class='btn-success', v-if='isLeader && !group.purchased.active', @click='upgradeGroup()')
| {{ $t('upgrade') }}
.button-container
button.btn.btn-primary(b-btn, @click="updateGuild", v-once, v-if='isLeader || isAdmin') {{ $t('edit') }}
.button-container
button.btn.btn-success(class='btn-success', v-if='!isMember', @click='join()') {{ $t('join') }}
.button-container
button.btn.btn-primary(v-once, @click='showInviteModal()') {{$t('invite')}}
// @TODO: hide the invitation button if there's an active group plan and the player is not the leader
.button-container
// @TODO: V2 button.btn.btn-primary(v-once, v-if='!isLeader') {{$t('messageGuildLeader')}} // Suggest making the button visible to the leader too - useful for them to test how the feature works or to send a note to themself. -- Alys
.button-container
// @TODO: V2 button.btn.btn-primary(v-once, v-if='isMember && !isParty') {{$t('donateGems')}} // Suggest removing the isMember restriction - it's okay if non-members donate to a public guild. Also probably allow it for parties if parties can buy imagery. -- Alys
div
quest-sidebar-section(:group='group', v-if='isParty')
sidebar-section(:title="$t('guildSummary')", v-if='!isParty')
p(v-markdown='group.summary')
sidebar-section(:title="$t('groupDescription')")
p(v-markdown='group.description')
sidebar-section(
:title="$t('challenges')",
:tooltip="$t('challengeDetails')"
)
group-challenges(:groupId='searchId')
div.text-center
button.btn.btn-danger(v-if='isMember', @click='clickLeave()') {{ isParty ? $t('leaveParty') : $t('leaveGroup') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@media (min-width: 1300px) {
.standard-page {
max-width: calc(100% - 430px);
}
.sidebar {
max-width: 430px !important;
}
}
h1 {
color: $purple-200;
}
.button-container {
margin-bottom: 1em;
button {
width: 100%;
}
}
.item-with-icon {
border-radius: 2px;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: 1em;
text-align: center;
min-width: 80px;
max-width: 120px;
height: 76px;
.svg-icon.shield, .svg-icon.gem {
width: 28px;
height: auto;
margin: 0 auto;
display: inline-block;
vertical-align: bottom;
margin-right: 0.5em;
}
.number {
font-size: 22px;
font-weight: bold;
}
.label {
margin-top: .5em;
}
}
.item-with-icon:hover {
cursor: pointer;
}
.sidebar {
background-color: $gray-600;
padding-bottom: 2em;
}
.buttons-wrapper {
padding-top: 2.8em;
}
.card {
margin: 2em 0;
padding: 1em;
h3.leader {
color: $purple-200;
}
.text {
font-size: 16px;
line-height: 1.43;
color: $gray-50;
}
}
.guild-background {
background-image: url('~assets/images/groups/grassy-meadow-backdrop.png');
height: 246px;
}
textarea {
height: 150px;
width: 100%;
background-color: $white;
border: solid 1px $gray-400;
font-size: 16px;
line-height: 1.43;
color: $gray-300;
padding: .5em;
}
textarea::-webkit-input-placeholder { /* Chrome/Opera/Safari */
font-style: italic;
}
textarea::-moz-placeholder { /* Firefox 19+ */
font-style: italic;
}
textarea:-ms-input-placeholder { /* IE 10+ */
font-style: italic;
}
textarea:-moz-placeholder { /* Firefox 18- */
font-style: italic;
}
.title-details {
padding-top: 1em;
padding-left: 1em;
}
.icon-row {
margin-top: 1em;
.number {
font-size: 22px;
font-weight: bold;
}
}
.chat-row {
margin-top: 2em;
.new-message-row {
position: relative;
}
.chat-actions {
margin-top: 1em;
.chat-receive-actions {
padding-left: 0;
button {
margin-bottom: 1em;
&:not(:last-child) {
margin-right: 1em;
}
}
}
.chat-send-actions {
padding-right: 0;
}
}
}
span.action {
font-size: 14px;
line-height: 1.33;
color: $gray-200;
font-weight: 500;
margin-right: 1em;
}
span.action .icon {
margin-right: .3em;
}
.hr {
width: 100%;
height: 20px;
border-bottom: 1px solid $gray-500;
text-align: center;
margin: 2em 0;
}
.no-notifications {
border-radius: 4px;
border: solid 1px $orange-10;
padding: 1em;
margin-top: 3em;
text-align: center;
font-size: 14px;
font-weight: bold;
color: $orange-50;
}
</style>
<script>
// @TODO: Break this down into components
import extend from 'lodash/extend';
import groupUtilities from 'client/mixins/groupsUtilities';
import styleHelper from 'client/mixins/styleHelper';
import { mapState } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import membersModal from './membersModal';
import startQuestModal from './startQuestModal';
import questDetailsModal from './questDetailsModal';
import participantListModal from './participantListModal';
import groupFormModal from './groupFormModal';
import groupChallenges from '../challenges/groupChallenges';
import groupGemsModal from 'client/components/groups/groupGemsModal';
import questSidebarSection from 'client/components/groups/questSidebarSection';
import markdownDirective from 'client/directives/markdown';
import chat from './chat';
import sidebarSection from '../sidebarSection';
import userLink from '../userLink';
import deleteIcon from 'assets/svg/delete.svg';
import copyIcon from 'assets/svg/copy.svg';
import likeIcon from 'assets/svg/like.svg';
import likedIcon from 'assets/svg/liked.svg';
import reportIcon from 'assets/svg/report.svg';
import gemIcon from 'assets/svg/gem.svg';
import questIcon from 'assets/svg/quest.svg';
import questBackground from 'assets/svg/quest-background-border.svg';
import goldGuildBadgeIcon from 'assets/svg/gold-guild-badge-small.svg';
import silverGuildBadgeIcon from 'assets/svg/silver-guild-badge-small.svg';
import bronzeGuildBadgeIcon from 'assets/svg/bronze-guild-badge-small.svg';
export default {
mixins: [groupUtilities, styleHelper],
props: ['groupId'],
components: {
membersModal,
startQuestModal,
groupFormModal,
groupChallenges,
questDetailsModal,
participantListModal,
groupGemsModal,
questSidebarSection,
sidebarSection,
userLink,
chat,
},
directives: {
markdown: markdownDirective,
},
data () {
return {
searchId: '',
group: {},
icons: Object.freeze({
like: likeIcon,
copy: copyIcon,
report: reportIcon,
delete: deleteIcon,
gem: gemIcon,
liked: likedIcon,
questIcon,
questBackground,
goldGuildBadgeIcon,
silverGuildBadgeIcon,
bronzeGuildBadgeIcon,
}),
members: [],
selectedQuest: {},
chat: {
submitDisable: false,
submitTimeout: null,
},
};
},
computed: {
...mapState({user: 'user.data'}),
partyStore () {
return this.$store.state.party;
},
isParty () {
return this.$route.path.startsWith('/party');
},
isLeader () {
return this.user._id === this.group.leader._id;
},
isAdmin () {
return Boolean(this.user.contributor.admin);
},
isMember () {
return this.isMemberOfGroup(this.user, this.group);
},
showNoNotificationsMessage () {
return this.group.memberCount > this.$store.state.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF;
},
},
mounted () {
if (this.isParty) this.searchId = 'party';
if (!this.searchId) this.searchId = this.groupId;
this.load();
},
beforeRouteUpdate (to, from, next) {
this.$set(this, 'searchId', to.params.groupId);
next();
},
watch: {
// call again the method if the route changes (when this route is already active)
$route: 'fetchGuild',
partyStore () {
if (this.$store.state.party._id) {
this.group = this.$store.state.party;
} else {
this.group = null;
this.$router.push('/');
}
},
},
methods: {
acceptCommunityGuidelines () {
this.$store.dispatch('user:set', {'flags.communityGuidelinesAccepted': true});
},
async load () {
if (this.isParty) {
this.searchId = 'party';
// @TODO: Set up from old client. Decide what we need and what we don't
// Check Desktop notifs
// Load invites
}
await this.fetchGuild();
// Fetch group members on load
this.members = await this.loadMembers({
groupId: this.group._id,
includeAllPublicFields: true,
});
this.$root.$on('updatedGroup', group => {
let updatedGroup = extend(this.group, group);
this.$set(this.group, updatedGroup);
});
},
/**
* Method for loading members of a group, with optional parameters for
* modifying requests.
*
* @param {Object} payload Used for modifying requests for members
*/
loadMembers (payload = null) {
// Remove unnecessary data
if (payload && payload.challengeId) {
delete payload.challengeId;
}
return this.$store.dispatch('members:getGroupMembers', payload);
},
showMemberModal () {
this.$root.$emit('habitica:show-member-modal', {
groupId: this.group._id,
group: this.group,
memberCount: this.group.memberCount,
viewingMembers: this.members,
fetchMoreMembers: this.loadMembers,
});
},
fetchRecentMessages () {
this.fetchGuild();
},
updateGuild () {
this.$store.state.editingGroup = this.group;
this.$root.$emit('bv::show::modal', 'guild-form');
},
showInviteModal () {
this.$root.$emit('inviteModal::inviteToGroup', this.group); // This event listener is initiated in ../header/index.vue
},
async fetchGuild () {
if (this.searchId === 'party' && !this.user.party._id) {
this.$root.$emit('bv::show::modal', 'create-party-modal');
return;
}
if (this.isParty) {
await this.$store.dispatch('party:getParty', true);
this.group = this.$store.state.party.data;
this.checkForAchievements();
} else {
const group = await this.$store.dispatch('guilds:getGroup', {groupId: this.searchId});
this.$set(this, 'group', group);
}
const groupId = this.searchId === 'party' ? this.user.party._id : this.searchId;
if (this.hasUnreadMessages(groupId)) {
// Delay by 1sec to make sure it returns after other requests that don't have the notification marked as read
setTimeout(() => {
this.$store.dispatch('chat:markChatSeen', {groupId});
this.$delete(this.user.newMessages, groupId);
}, 1000);
}
},
hasUnreadMessages (groupId) {
if (this.user.newMessages[groupId]) return true;
return this.user.notifications.some(n => {
return n.type === 'NEW_CHAT_MESSAGE' && n.data.group.id === groupId;
});
},
checkForAchievements () {
// Checks if user's party has reached 2 players for the first time.
if (!this.user.achievements.partyUp && this.group.memberCount >= 2) {
// @TODO
// User.set({'achievements.partyUp':true});
// Achievement.displayAchievement('partyUp');
}
// Checks if user's party has reached 4 players for the first time.
if (!this.user.achievements.partyOn && this.group.memberCount >= 4) {
// @TODO
// User.set({'achievements.partyOn':true});
// Achievement.displayAchievement('partyOn');
}
},
async join () {
if (this.group.cancelledPlan && !confirm(this.$t('aboutToJoinCancelledGroupPlan'))) {
return;
}
await this.$store.dispatch('guilds:join', {groupId: this.group._id, type: 'guild'});
},
clickLeave () {
Analytics.track({
hitType: 'event',
eventCategory: 'button',
eventAction: 'click',
eventLabel: 'Leave Party',
});
// @TODO: Get challenges and ask to keep or remove
if (!confirm('Are you sure you want to leave?')) return;
let keep = true;
this.leave(keep);
},
async leave (keepTasks) {
let keepChallenges = 'remain-in-challenges';
let data = {
groupId: this.group._id,
keep: keepTasks,
keepChallenges,
};
if (this.isParty) {
data.type = 'party';
Analytics.updateUser({partySize: null, partyID: null});
this.$store.state.partyMembers = [];
}
await this.$store.dispatch('guilds:leave', data);
if (this.isParty) {
this.$router.push({name: 'tasks'});
}
},
upgradeGroup () {
this.$store.state.upgradingGroup = this.group;
this.$router.push('/group-plans');
},
showGroupGems () {
this.$root.$emit('bv::show::modal', 'group-gems-modal');
},
},
};
</script>
@@ -0,0 +1,454 @@
<template lang="pug">
b-modal#guild-form(:title="title", :hide-footer="true", size='lg')
form(@submit.stop.prevent="submit")
.form-group
label
strong(v-once) {{$t('name')}} *
b-form-input(type="text", :placeholder="$t('newGuildPlaceholder')", v-model="workingGroup.name")
.form-group
label
strong(v-once) {{$t('privacySettings')}} *
br
.custom-control.custom-checkbox
input.custom-control-input#onlyLeaderCreatesChallenges(type="checkbox", v-model="workingGroup.onlyLeaderCreatesChallenges")
label.custom-control-label(v-once, for="onlyLeaderCreatesChallenges") {{ $t('onlyLeaderCreatesChallenges') }}
#groupPrivateDescription1.icon(:title="$t('privateDescription')")
.svg-icon(v-html='icons.information')
b-tooltip(
:title="$t('onlyLeaderCreatesChallengesDetail')",
target="groupPrivateDescription1",
)
// br
// @TODO Implement in V2 .custom-control.custom-checkbox
input.custom-control-input(type="checkbox", v-model="workingGroup.guildLeaderCantBeMessaged")
label.custom-control-label(v-once) {{ $t('guildLeaderCantBeMessaged') }}
// "guildLeaderCantBeMessaged": "Leader can not be messaged directly",
// @TODO discuss the impact of this with moderators before implementing
br
.custom-control.custom-checkbox(v-if='!isParty && !this.workingGroup.id')
input.custom-control-input#privateGuild(type="checkbox", v-model="workingGroup.privateGuild")
label.custom-control-label(v-once, for="privateGuild") {{ $t('privateGuild') }}
#groupPrivateDescription2.icon(:title="$t('privateDescription')")
.svg-icon(v-html='icons.information')
b-tooltip(
:title="$t('privateDescription')",
target="groupPrivateDescription2",
)
// br
// @TODO: Implement in v2 .custom-control.custom-checkbox(v-if='!creatingParty')
input.custom-control-input(type="checkbox", v-model="workingGroup.allowGuildInvitationsFromNonMembers")
label.custom-control-label(v-once) {{ $t('allowGuildInvitationsFromNonMembers') }}
// "allowGuildInvitationsFromNonMembers": "Allow Guild invitations from non-members",
.form-group(v-if='!isParty')
label
strong(v-once) {{$t('guildSummary')}} *
div.summary-count {{ $t('charactersRemaining', {characters: charactersRemaining}) }}
textarea.form-control.summary-textarea(:placeholder="isParty ? $t('partyDescriptionPlaceholder') : $t('guildSummaryPlaceholder')", v-model="workingGroup.summary")
// @TODO: need summary only for PUBLIC GUILDS, not for tavern, private guilds, or party
.form-group
label
strong(v-once) {{$t('groupDescription')}} *
a.float-right(v-markdown='$t("markdownFormattingHelp")')
textarea.form-control.description-textarea(type="text", textarea, :placeholder="isParty ? $t('partyDescriptionPlaceholder') : $t('guildDescriptionPlaceholder')", v-model="workingGroup.description")
.form-group(v-if='creatingParty && !workingGroup.id')
span
toggleSwitch(:label="$t('inviteMembersNow')", v-model='inviteMembers')
.form-group(style='position: relative;', v-if='!creatingParty && !isParty')
label
strong(v-once) {{$t('categories')}} *
div.category-wrap(@click.prevent="toggleCategorySelect")
span.category-select(v-if='workingGroup.categories.length === 0') {{$t('none')}}
.category-label(v-for='category in workingGroup.categories') {{$t(categoriesHashByKey[category])}}
.category-box(v-if="showCategorySelect")
.form-check(
v-for="group in categoryOptions",
:key="group.key",
v-if='group.key !== "habitica_official" || user.contributor.admin'
)
.custom-control.custom-checkbox
input.custom-control-input(:id="`category-${group.key}`", type="checkbox", :value="group.key", v-model="workingGroup.categories")
label.custom-control-label(v-once, :for="`category-${group.key}`") {{ $t(group.label) }}
button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}}
// @TODO: need categories only for PUBLIC GUILDS, not for tavern, private guilds, or party
.form-group(v-if='inviteMembers && !workingGroup.id')
label
strong(v-once) Invite via Email or User ID
p(v-once) {{$t('inviteMembersHowTo')}} *
div
div(v-for='(member, index) in membersToInvite')
input(type='text', v-model='member.value')
button(@click.prevent='removeMemberToInvite(index)') Remove
div
input(type='text', placeholder='Email address or User ID', v-model='newMemberToInvite.value')
button(@click.prevent='addMemberToInvite()') Add
.form-group.text-center
div.item-with-icon(v-if='!this.workingGroup.id')
.svg-icon(v-html="icons.gem")
span.count 4
button.btn.btn-primary.btn-md(v-if='!workingGroup.id', :disabled='!workingGroup.name || !workingGroup.description') {{ creatingParty ? $t('createParty') : $t('createGuild') }}
button.btn.btn-primary.btn-md(v-if='workingGroup.id', :disabled='!workingGroup.name || !workingGroup.description') {{ isParty ? $t('updateParty') : $t('updateGuild') }}
.gem-description(v-once, v-if='!this.workingGroup.id') {{ $t('guildGemCostInfo') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.custom-control-input {
z-index: 1 !important;
}
.svg-icon {
width: 16px;
}
textarea {
height: 150px;
}
.summary-count, .gem-description {
font-size: 12px;
line-height: 1.33;
margin-top: 1em;
color: $gray-200;
}
.summary-count {
text-align: right;
}
.gem-description {
text-align: center;
}
.summary-textarea {
height: 90px;
}
.description-textarea {
height: 220px;
}
.item-with-icon {
display: inline-block;
img {
height: 20px;
margin-right: .5em;
}
.count {
font-size: 14px;
font-weight: bold;
margin-right: 1em;
color: $green-10;
}
}
.icon {
margin-left: .5em;
display: inline-block;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import toggleSwitch from 'client/components/ui/toggleSwitch';
import groupMemberSearchDropdown from 'client/components/members/groupMemberSearchDropdown';
import markdownDirective from 'client/directives/markdown';
import gemIcon from 'assets/svg/gem.svg';
import informationIcon from 'assets/svg/information.svg';
import { MAX_SUMMARY_SIZE_FOR_GUILDS } from '../../../common/script/constants';
// @TODO: Not sure the best way to pass party creating status
// Since we need the modal in the header, passing props doesn't work
// because we can't import the create group in the index of groups
// I think state is the correct spot, but maybe we should separate into
// two modals?
export default {
components: {
toggleSwitch,
groupMemberSearchDropdown,
},
directives: {
markdown: markdownDirective,
},
data () {
let data = {
workingGroup: {
id: '',
name: '',
type: 'guild',
privacy: 'private',
summary: '',
description: '',
categories: [],
onlyLeaderCreatesChallenges: true,
guildLeaderCantBeMessaged: true,
privateGuild: true,
allowGuildInvitationsFromNonMembers: true,
},
categoryOptions: [
{
label: 'habitica_official',
key: 'habitica_official',
},
{
label: 'academics',
key: 'academics',
},
{
label: 'advocacy_causes',
key: 'advocacy_causes',
},
{
label: 'creativity',
key: 'creativity',
},
{
label: 'entertainment',
key: 'entertainment',
},
{
label: 'finance',
key: 'finance',
},
{
label: 'health_fitness',
key: 'health_fitness',
},
{
label: 'hobbies_occupations',
key: 'hobbies_occupations',
},
{
label: 'location_based',
key: 'location_based',
},
{
label: 'mental_health',
key: 'mental_health',
},
{
label: 'getting_organized',
key: 'getting_organized',
},
{
label: 'recovery_support_groups',
key: 'recovery_support_groups',
},
{
label: 'spirituality',
key: 'spirituality',
},
{
label: 'time_management',
key: 'time_management',
},
],
showCategorySelect: false,
members: [],
inviteMembers: false,
newMemberToInvite: {
value: '',
type: '',
},
membersToInvite: [],
};
let hashedCategories = {};
data.categoryOptions.forEach((category) => {
hashedCategories[category.key] = category.label;
});
data.categoriesHashByKey = hashedCategories;
data.icons = Object.freeze({
gem: gemIcon,
information: informationIcon,
});
return data;
},
computed: {
...mapState({user: 'user.data'}),
editingGroup () {
return this.$store.state.editingGroup;
},
charactersRemaining () {
let currentLength = this.workingGroup.summary ? this.workingGroup.summary.length : 0;
return MAX_SUMMARY_SIZE_FOR_GUILDS - currentLength;
},
title () {
if (this.creatingParty) return this.$t('createParty');
if (!this.workingGroup._id && !this.workingGroup.id) return this.$t('createGuild');
if (this.isParty) return this.$t('updateParty');
return this.$t('updateGuild');
},
creatingParty () {
return this.$store.state.groupFormOptions.createParty;
},
isParty () {
return this.workingGroup.type === 'party';
},
},
watch: {
editingGroup () {
let editingGroup = this.editingGroup;
if (!editingGroup._id) {
this.resetWorkingGroup();
return;
}
this.workingGroup.name = editingGroup.name;
this.workingGroup.type = editingGroup.type;
this.workingGroup.privateGuild = true;
if (editingGroup.privacy === 'public') {
this.workingGroup.privateGuild = false;
}
if (editingGroup.categories) {
editingGroup.categories.forEach(category => {
this.workingGroup.categories.push(category.slug);
});
}
if (editingGroup.summary) this.workingGroup.summary = editingGroup.summary;
if (editingGroup.description) this.workingGroup.description = editingGroup.description;
if (editingGroup._id) this.workingGroup.id = editingGroup._id;
this.workingGroup.onlyLeaderCreatesChallenges = editingGroup.leaderOnly.challenges;
this.workingGroup.leader = editingGroup.leader;
if (editingGroup._id) this.getMembers();
},
},
methods: {
async getMembers () {
if (!this.workingGroup.id) return;
let members = await this.$store.dispatch('members:getGroupMembers', {
groupId: this.workingGroup.id,
includeAllPublicFields: true,
});
this.members = members;
},
addMemberToInvite () {
// @TODO: determine type
this.membersToInvite.push(this.newMemberToInvite);
this.newMemberToInvite = {
value: '',
type: '',
};
},
removeMemberToInvite (index) {
this.membersToInvite.splice(index, 1);
},
toggleCategorySelect () {
this.showCategorySelect = !this.showCategorySelect;
},
async submit () {
if (this.$store.state.user.data.balance < 1 && !this.workingGroup.id) {
// @TODO: Add proper notifications
alert(this.$t('notEnoughGems'));
return;
// @TODO return $rootScope.openModal('buyGems', {track:"Gems > Gems > Create Group"});
}
let errors = [];
if (!this.workingGroup.name) errors.push(this.$t('nameRequired'));
if (!this.workingGroup.summary) errors.push(this.$t('summaryRequired'));
if (this.workingGroup.summary.length > MAX_SUMMARY_SIZE_FOR_GUILDS) errors.push(this.$t('summaryTooLong'));
if (!this.workingGroup.description) errors.push(this.$t('descriptionRequired'));
if (!this.isParty && (!this.workingGroup.categories || this.workingGroup.categories.length === 0)) errors.push(this.$t('categoiresRequired'));
if (errors.length > 0) {
alert(errors.join('\n'));
return;
}
// @TODO: Add proper notifications
if (!this.workingGroup.id && !confirm(this.$t('confirmGuild'))) return;
if (!this.workingGroup.privateGuild) {
this.workingGroup.privacy = 'public';
}
this.workingGroup.leaderOnly = {
challenges: this.workingGroup.onlyLeaderCreatesChallenges,
};
let categoryKeys = this.workingGroup.categories;
let serverCategories = [];
categoryKeys.forEach(key => {
let catName = this.categoriesHashByKey[key];
serverCategories.push({
slug: key,
name: catName,
});
});
this.workingGroup.categories = serverCategories;
let groupData = Object.assign({}, this.workingGroup);
let newgroup;
if (groupData.id) {
await this.$store.dispatch('guilds:update', {group: groupData});
this.$root.$emit('updatedGroup', this.workingGroup);
// @TODO: this doesn't work because of the async resource
// if (updatedGroup.type === 'party') this.$store.state.party = {data: updatedGroup};
} else {
newgroup = await this.$store.dispatch('guilds:create', {group: groupData});
this.$store.state.user.data.balance -= 1;
}
this.$store.state.editingGroup = {};
this.workingGroup = {
name: '',
type: 'guild',
privacy: 'private',
description: '',
categories: [],
onlyLeaderCreatesChallenges: true,
guildLeaderCantBeMessaged: true,
privateGuild: true,
allowGuildInvitationsFromNonMembers: true,
};
if (newgroup && newgroup._id) {
this.$router.push(`/groups/guild/${newgroup._id}`);
}
this.$root.$emit('bv::hide::modal', 'guild-form');
},
resetWorkingGroup () {
this.workingGroup = {
id: '',
name: '',
type: 'guild',
privacy: 'private',
summary: '',
description: '',
categories: [],
onlyLeaderCreatesChallenges: true,
guildLeaderCantBeMessaged: true,
privateGuild: true,
allowGuildInvitationsFromNonMembers: true,
};
},
},
};
</script>
@@ -0,0 +1,27 @@
<template lang="pug">
b-modal#group-gems-modal(:title="$t('groupGems')", size='md', :hide-footer="true")
.modal-body
.row
.col-6.offset-3
h3 {{ $t('groupGemsDesc') }}
.modal-footer
.col-12.text-center
button.btn.btn-primary(@click='close()') {{$t('close')}}
</template>
<style scoped>
.modal-body {
margin-top: 1em;
margin-bottom: 1em;
}
</style>
<script>
export default {
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'group-gems-modal');
},
},
};
</script>
@@ -0,0 +1,382 @@
<template lang="pug">
// @TODO: Move to group plans folder
div
div
.header
h1.text-center Need more for your Group?
.row
.col-8.offset-2.text-center
h2.sub-text {{ $t('groupBenefitsDescription') }}
.container.benefits
.row
.col-4
.box
img.box1(src='~client/assets/images/group-plans/group-14@3x.png')
hr
h2 {{ $t('teamBasedTasks') }}
p Set up an easily-viewed shared task list for the group. Assign tasks to your fellow group members, or let them claim their own tasks to make it clear what everyone is working on!
.col-4
.box
img.box2(src='~client/assets/images/group-plans/group-12@3x.png')
hr
h2 Group Management Controls
p Use task approvals to verify that a task that was really completed, add Group Managers to share responsibilities, and enjoy a private group chat for all team members.
.col-4
.box
img.box3(src='~client/assets/images/group-plans/group-13@3x.png')
hr
h2 In-Game Benefits
p Group members get an exclusive Jackalope Mount, as well as full subscription benefits, including special monthly equipment sets and the ability to buy gems with gold.
#upgrading-group.container.payment-options(v-if='upgradingGroup._id')
h1.text-center.purple-header Are you ready to upgrade?
.row
.col-12.text-center
.purple-box
.amount-section
.dollar $
.number 9
.name Group Owner Subscription
.plus
.svg-icon(v-html="icons.positiveIcon")
.amount-section
.dollar $
.number 3
.name Each Individual Group Member
.box.payment-providers
h3 Choose your payment method
.payments-column
button.purchase.btn.btn-primary.payment-button.payment-item(@click='pay(PAYMENTS.STRIPE)')
.svg-icon.credit-card-icon(v-html="icons.creditCardIcon")
| {{ $t('card') }}
amazon-button.payment-item(:amazon-data="pay(PAYMENTS.AMAZON)")
.container.col-6.offset-3.create-option(v-if='!upgradingGroup._id')
.row
h1.col-12.text-center.purple-header Create your Group today!
.row
.col-12.text-center
button.btn.btn-primary.create-group(@click='launchModal("create")') Create Your New Group
.row.pricing
.col-5
.dollar $
.number 9
.name
div Group Owner
div Subscription
.col-1
.plus +
.col-6
.dollar $
.number 3
.name
div Each Additional
div Member
b-modal#group-plan-modal(title="Select Payment", size='md', hide-footer=true)
.col-12(v-if='activePage === PAGES.CREATE_GROUP')
.form-group
label.control-label(for='new-group-name') Name
input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder="Name", v-model='newGroup.name')
.form-group
label(for='new-group-description') {{ $t('description') }}
textarea.form-control#new-group-description.option-content(cols='3', :placeholder="$t('description')", v-model='newGroup.description')
.form-group(v-if='type === "guild"')
.custom-control.custom-radio
input.custom-control-input(type='radio', name='new-group-privacy', value='private', v-model='newGroup.privacy')
label.custom-control-label {{ $t('inviteOnly') }}
.form-group
.custom-control.custom-checkbox
input.custom-control-input(type='checkbox', v-model='newGroup.leaderOnly.challenges' id='create-group-leaderOnlyChallenges-checkbox')
label.custom-control-label(for='create-group-leaderOnlyChallenges-checkbox') {{ $t('leaderOnlyChallenges') }}
.form-group(v-if='type === "party"')
button.btn.btn-secondary.form-control(@click='createGroup()', :value="$t('createGroupPlan')")
.form-group
button.btn.btn-primary.btn-lg.btn-block(@click="createGroup()", :disabled="!newGroupIsReady") {{ $t('createGroupPlan') }}
.col-12(v-if='activePage === PAGES.PAY')
.text-center
h3 Choose your payment method
.payments-column.mx-auto
button.purchase.btn.btn-primary.payment-button.payment-item(@click='pay(PAYMENTS.STRIPE)')
.svg-icon.credit-card-icon(v-html="icons.creditCardIcon")
| {{ $t('card') }}
amazon-button.payment-item(:amazon-data="pay(PAYMENTS.AMAZON)")
</template>
<style lang="scss" scoped>
#upgrading-group {
.amount-section {
position: relative;
}
.dollar {
position: absolute;
left: -1em;
top: 1em;
}
.purple-box {
color: #bda8ff;
margin-bottom: 2em;
}
.number {
font-weight: bold;
color: #fff;
}
.plus .svg-icon{
width: 24px;
}
.payment-providers {
width: 350px;
}
}
.header {
background: #432874;
background: linear-gradient(180deg, #4F2A93 0%, #432874 100%);
color: #fff;
padding: 2em;
height: 340px;
margin-bottom: 2em;
margin-left: -12px;
margin-right: -12px;
h1 {
font-size: 48px;
line-height: 1.16;
margin-top: 12px;
color: #fff;
}
h2.sub-text {
color: #D5C8FF;
font-size: 24px;
font-weight: 400;
line-height: 1.33;
}
}
.benefits {
margin-top: -10em;
.box {
height: 416px;
}
h2 {
color: #6133b4;
}
}
.box {
border-radius: 2px;
background-color: #ffffff;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
padding: 2em;
text-align: center;
display: inline-block !important;
vertical-align: bottom;
margin-right: 1em;
img {
margin: 0 auto;
margin-top: 2em;
margin-bottom: 1em;
}
}
img.box1 {
width: 266px;
}
img.box2 {
margin-top: 3.5em;
width: 262px;
margin-bottom: 3.7em;
}
img.box3 {
width: 225px;
margin-bottom: 3.0em;
}
button.create-group {
width: 330px;
height: 96px;
}
.purple-header {
color: #6133b4;
font-size: 48px;
margin-top: 1em;
}
.pricing {
margin-top: 2em;
margin-bottom: 4em;
.dollar, .number, .name {
display: inline-block;
vertical-align: bottom;
color: #a5a1ac;
}
.plus {
font-size: 34px;
color: #a5a1ac;
}
.dollar {
margin-bottom: 1.5em;
font-size: 32px;
font-weight: bold;
}
.name {
font-size: 24px;
margin-bottom: .8em;
margin-left: .5em;
}
.number {
font-size: 72px;
font-weight: bolder;
}
}
.payment-options {
margin-bottom: 4em;
.purple-box {
background-color: #4f2a93;
color: #fff;
padding: .5em;
border-radius: 8px;
width: 200px;
height: 215px;
.dollar {
}
.number {
font-size: 60px;
}
.name {
width: 100px;
margin-left: .3em;
}
.plus {
width: 100%;
text-align: center;
}
div {
display: inline-block;
}
}
.box, .purple-box {
display: inline-block;
vertical-align: bottom;
}
}
</style>
<script>
import paymentsMixin from '../../mixins/payments';
import { mapState } from 'client/libs/store';
import positiveIcon from 'assets/svg/positive.svg';
import creditCardIcon from 'assets/svg/credit-card-icon.svg';
import amazonButton from 'client/components/payments/amazonButton';
export default {
mixins: [paymentsMixin],
components: {
amazonButton,
},
data () {
return {
amazonPayments: {},
icons: Object.freeze({
creditCardIcon,
positiveIcon,
}),
PAGES: {
CREATE_GROUP: 'create-group',
UPGRADE_GROUP: 'upgrade-group',
PAY: 'pay',
},
PAYMENTS: {
AMAZON: 'amazon',
STRIPE: 'stripe',
},
paymentMethod: '',
newGroup: {
type: 'guild',
privacy: 'private',
name: '',
leaderOnly: {
challenges: false,
},
},
activePage: '',
type: 'guild', // Guild or Party @TODO enum this
};
},
mounted () {
this.activePage = this.PAGES.BENEFITS;
},
computed: {
newGroupIsReady () {
return Boolean(this.newGroup.name);
},
upgradingGroup () {
return this.$store.state.upgradingGroup;
},
// @TODO: can we move this to payment mixin?
...mapState({user: 'user.data'}),
},
methods: {
launchModal () {
this.changePage(this.PAGES.CREATE_GROUP);
this.$root.$emit('bv::show::modal', 'group-plan-modal');
},
changePage (page) {
this.activePage = page;
window.scrollTo(0, 0);
},
createGroup () {
this.changePage(this.PAGES.PAY);
},
pay (paymentMethod) {
const subscriptionKey = 'group_monthly'; // @TODO: Get from content API?
const paymentData = {
subscription: subscriptionKey,
coupon: null,
};
if (this.upgradingGroup && this.upgradingGroup._id) {
paymentData.groupId = this.upgradingGroup._id;
paymentData.group = this.upgradingGroup;
} else {
paymentData.groupToCreate = this.newGroup;
}
this.paymentMethod = paymentMethod;
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
this.showStripe(paymentData);
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
paymentData.type = 'subscription';
return paymentData;
}
},
},
};
</script>
@@ -0,0 +1,24 @@
<template lang="pug">
.row
secondary-menu.col-12
router-link.nav-link(:to="{name: 'tavern'}", exact, :class="{'active': $route.name === 'tavern'}") {{ $t('tavern') }}
router-link.nav-link(:to="{name: 'myGuilds'}", :class="{'active': $route.name === 'myGuilds'}") {{ $t('myGuilds') }}
router-link.nav-link(:to="{name: 'guildsDiscovery'}", :class="{'active': $route.name === 'guildsDiscovery'}") {{ $t('guildsDiscovery') }}
.col-12
router-view
group-form-modal
</template>
<script>
import groupFormModal from './groupFormModal';
import SecondaryMenu from 'client/components/secondaryMenu';
export default {
components: {
groupFormModal,
SecondaryMenu,
},
};
</script>
@@ -0,0 +1,199 @@
<template lang="pug">
b-modal#invite-modal(:title='$t(`inviteTo${groupType}`)', :hide-footer='true')
div
strong {{ $t('inviteEmailUsername') }}
.small {{ $t('inviteEmailUsernameInfo') }}
div(v-for='(invite, index) in invites')
.input-group
.d-flex.align-items-center.justify-content-center(v-if='index === invites.length - 1 && invite.text.length === 0')
.svg-icon.positive-icon(v-html='icons.positiveIcon')
input.form-control(
type='text',
:placeholder='$t("emailOrUsernameInvite")',
v-model='invite.text',
v-on:keyup='expandInviteList',
v-on:change='checkInviteList',
:class='{"input-valid": invite.valid, "is-invalid input-invalid": invite.valid === false}',
)
.input-error.text-center.mt-2(v-if="invite.error") {{ invite.error }}
.modal-footer.d-flex.justify-content-center
a.mr-3(@click='close()') {{ $t('cancel') }}
button.btn.btn-primary(@click='sendInvites()', :class='{disabled: cannotSubmit}', :disabled='cannotSubmit') {{ $t('sendInvitations') }}
</template>
<style lang="scss">
#invite-modal___BV_modal_outer_ {
.modal-content {
padding: 0rem 0.25rem;
}
}
#invite-modal___BV_modal_header_.modal-header {
border-bottom: 0px;
}
#invite-modal___BV_modal_header_ {
.modal-title {
color: #4F2A93;
font-size: 24px;
}
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
a:not([href]) {
color: $blue-10;
font-size: 16px;
}
.form-control {
border: 0px;
color: $gray-50;
}
.input-error {
color: $red-50;
font-size: 90%;
}
.input-group {
border-radius: 2px;
border: solid 1px $gray-400;
margin-top: 0.5rem;
}
::placeholder {
color: $gray-200;
opacity: 1;
}
.input-group:focus-within {
border-color: $purple-500;
}
.modal-footer {
border: 0px;
}
.positive-icon {
color: $green-10;
width: 10px;
margin: auto 0rem auto 1rem;
}
.small {
color: $gray-200;
font-size: 12px;
margin: 0.5rem 0rem 1rem;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import clone from 'lodash/clone';
import debounce from 'lodash/debounce';
import filter from 'lodash/filter';
import forEach from 'lodash/forEach';
import isEmail from 'validator/lib/isEmail';
import isUUID from 'validator/lib/isUUID';
import notifications from 'client/mixins/notifications';
import positiveIcon from 'assets/svg/positive.svg';
const INVITE_DEFAULTS = {text: '', error: null, valid: null};
export default {
computed: {
...mapState({user: 'user.data'}),
cannotSubmit () {
const filteredInvites = filter(this.invites, (invite) => {
return invite.text.length > 0 && !invite.valid;
});
if (filteredInvites.length > 0) return true;
},
inviter () {
return this.user.profile.name;
},
},
data () {
return {
invites: [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)],
icons: Object.freeze({
positiveIcon,
}),
};
},
methods: {
checkInviteList: debounce(function checkList () {
this.invites = filter(this.invites, (invite, index) => {
return invite.text.length > 0 || index === this.invites.length - 1;
});
while (this.invites.length < 2) this.invites.push(clone(INVITE_DEFAULTS));
forEach(this.invites, (value, index) => {
if (value.text.length < 1 || isEmail(value.text)) {
return this.fillErrors(index);
}
if (isUUID(value.text)) {
this.$store.dispatch('user:userLookup', {uuid: value.text})
.then(res => {
return this.fillErrors(index, res);
});
} else {
let searchUsername = value.text;
if (searchUsername[0] === '@') searchUsername = searchUsername.slice(1, searchUsername.length);
this.$store.dispatch('user:userLookup', {username: searchUsername})
.then(res => {
return this.fillErrors(index, res);
});
}
});
}, 250),
expandInviteList () {
if (this.invites[this.invites.length - 1].text.length > 0) this.invites.push(clone(INVITE_DEFAULTS));
},
fillErrors (index, res) {
if (!res || res.status === 200) {
this.invites[index].error = null;
if (this.invites[index].text.length < 1) return this.invites[index].valid = null;
return this.invites[index].valid = true;
}
this.invites[index].error = res.response.data.message;
return this.invites[index].valid = false;
},
close () {
this.invites = [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)];
this.$root.$emit('bv::hide::modal', 'invite-modal');
},
async sendInvites () {
let invitationDetails = {
inviter: this.inviter,
emails: [],
uuids: [],
usernames: [],
};
forEach(this.invites, (invite) => {
if (invite.text.length < 1) return;
if (isEmail(invite.text)) {
invitationDetails.emails.push({email: invite.text});
} else if (isUUID(invite.text)) {
invitationDetails.uuids.push(invite.text);
} else {
invitationDetails.usernames.push(invite.text);
}
});
await this.$store.dispatch('guilds:invite', {
invitationDetails,
groupId: this.group._id,
});
const invitesSent = invitationDetails.emails.length + invitationDetails.uuids.length + invitationDetails.usernames.length;
let invitationString = invitesSent > 1 ? 'invitationsSent' : 'invitationSent';
this.text(this.$t(invitationString));
this.close();
},
},
mixins: [notifications],
props: ['group', 'groupType'],
};
</script>
@@ -0,0 +1,515 @@
<template lang="pug">
// @TODO: Move this to a member directory
div
remove-member-modal(:member-to-remove='memberToRemove', :group-id='this.groupId' @member-removed='memberRemoved')
b-modal#members-modal(:title="$t('createGuild')", size='md', :hide-footer='true')
.header-wrap(slot="modal-header")
.row
.col-6
h1(v-once) {{$t('members')}}
.col-6
button(type="button" aria-label="Close" class="close", @click='close()')
span(aria-hidden="true") ×
.row.d-flex.align-items-center
.col-4
input.form-control.input-search(type="text", :placeholder="$t('search')", v-model='searchTerm')
.col
select.form-control(@change='changeSortOption($event)')
option(v-for='sortOption in sortOptions', :value='sortOption.value') {{sortOption.text}}
.col-3
select.form-control(@change='changeSortDirection($event)')
option(v-for='sortDirection in sortDirections', :value='sortDirection.value') {{sortDirection.text}}
.row.apply-options.d-flex.justify-content-center(v-if='sortDirty && group.type === "party"')
a(@click='applySortOptions()') {{ $t('applySortToHeader') }}
.row(v-if='invites.length > 0')
.col-6.offset-3.nav
.nav-item(@click='viewMembers()', :class="{active: selectedPage === 'members'}") {{ $t('members') }}
.nav-item(@click='viewInvites()', :class="{active: selectedPage === 'invites'}") {{ $t('invites') }}
div(v-if='selectedPage === "members"')
.row(v-for='(member, index) in sortedMembers')
.col-11.no-padding-left
member-details(:member='member')
.col-1.actions
b-dropdown(right=true)
.svg-icon.inline.dots(slot='button-content', v-html="icons.dots")
b-dropdown-item(@click='sendMessage(member)')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.messageIcon")
span.text {{$t('sendMessage')}}
b-dropdown-item(@click='promoteToLeader(member)', v-if='shouldShowLeaderFunctions(member._id)')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.starIcon")
span.text {{$t('promoteToLeader')}}
b-dropdown-item(@click='addManager(member._id)', v-if='shouldShowAddManager(member._id)')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.starIcon")
span.text {{$t('addManager')}}
b-dropdown-item(@click='removeManager(member._id)', v-if='shouldShowRemoveManager(member._id)')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.removeIcon")
span.text {{$t('removeManager2')}}
b-dropdown-item(@click='viewProgress(member)', v-if='challengeId')
span.dropdown-icon-item
span.text {{ $t('viewProgress') }}
b-dropdown-item(@click='removeMember(member, index)', v-if='shouldShowLeaderFunctions(member._id)')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.removeIcon")
span.text {{$t('removeMember')}}
.row(v-if='isLoadMoreAvailable')
.col-12.text-center
button.btn.btn-secondary(@click='loadMoreMembers()') {{ $t('loadMore') }}
.row.gradient(v-if='members.length > 3')
div(v-if='selectedPage === "invites"')
.row(v-for='(member, index) in invites')
.col-11.no-padding-left
member-details(:member='member')
.col-1.actions
b-dropdown(right=true)
.svg-icon.inline.dots(slot='button-content', v-html="icons.dots")
b-dropdown-item(@click='removeInvite(member, index)', v-if='isLeader')
span.dropdown-icon-item
.svg-icon.inline(v-html="icons.removeIcon", v-if='isLeader')
span.text {{$t('removeInvite')}}
.modal-footer
button.btn.btn-primary(@click='close()') {{ $t('close') }}
</template>
<style lang='scss'>
#members-modal {
.modal-header {
background-color: #edecee;
border-radius: 8px 8px 0 0;
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
}
.modal-footer {
background-color: #edecee;
border-radius: 0 0 8px 8px;
}
.small-text, .character-name {
color: #878190;
}
.no-padding-left {
padding-left: 0;
}
.modal-body {
padding-left: 0;
padding-right: 0;
}
.member-details {
margin: 0;
}
.actions .dropdown-toggle::after {
content: none !important;
}
}
</style>
<style lang='scss' scoped>
.apply-options {
padding: 1em;
margin: 0;
background-color: #f9f9f9;
color: #2995cd;
}
.header-wrap {
width: 100%;
}
.form-control {
font-size: 0.9rem;
}
h1 {
color: #4f2a93;
}
.actions {
padding-top: 5em;
.dots {
height: 16px;
width: 4px;
}
.btn-group {
margin-left: -2em;
margin-top: -2em;
}
.action-icon {
margin-right: 1em;
}
}
#members-modal_modal_body {
padding: 0;
max-height: 450px;
.col-8 {
margin-left: 0;
}
.member-details {
margin: 0;
}
.member-stats {
width: 382px;
height: 147px;
}
.gradient {
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0), #ffffff);
height: 50px;
width: 100%;
position: absolute;
bottom: 0px;
margin-left: -15px;
}
}
.dropdown-icon-item .svg-icon {
width: 20px;
}
.nav {
font-weight: bold;
margin-bottom: .5em;
margin-top: .5em;
}
.nav-item {
display: inline-block;
font-size: 16px;
margin: 0 auto;
padding: .5em;
color: #878190;
}
.nav-item:hover, .nav-item.active {
color: #4f2a93;
border-bottom: 2px solid #4f2a93;
cursor: pointer;
}
</style>
<script>
import orderBy from 'lodash/orderBy';
import isEmpty from 'lodash/isEmpty';
import { mapState } from 'client/libs/store';
import removeMemberModal from 'client/components/members/removeMemberModal';
import MemberDetails from '../memberDetails';
import removeIcon from 'assets/members/remove.svg';
import messageIcon from 'assets/members/message.svg';
import starIcon from 'assets/members/star.svg';
import dots from 'assets/svg/dots.svg';
export default {
props: ['hideBadge'],
components: {
MemberDetails,
removeMemberModal,
},
data () {
return {
sortOption: {},
sortDirty: false,
selectedPage: 'members',
members: [],
invites: [],
memberToRemove: {},
sortOptions: [
{
value: 'stats.class',
text: this.$t('sortClass'),
},
{
value: 'preferences.background',
text: this.$t('sortBackground'),
},
{
value: 'auth.timestamps.created',
text: this.$t('sortDateJoined'),
},
{
value: 'auth.timestamps.loggedin',
text: this.$t('sortLogin'),
},
{
value: 'stats.lvl',
text: this.$t('sortLevel'),
},
{
value: 'profile.name',
text: this.$t('sortName'),
},
{
value: 'contributor.level',
text: this.$t('sortTier'),
},
],
sortDirections: [
{
value: 'asc',
text: this.$t('ascendingAbbrev'),
},
{
value: 'desc',
text: this.$t('descendingAbbrev'),
},
],
searchTerm: '',
icons: Object.freeze({
removeIcon,
messageIcon,
starIcon,
dots,
}),
userIdToMessage: '',
};
},
mounted () {
this.$root.$on('habitica:show-member-modal', (data) => {
// @TODO: Remove store
this.$store.state.memberModalOptions.challengeId = data.challengeId;
this.$store.state.memberModalOptions.groupId = data.groupId;
this.$store.state.memberModalOptions.group = data.group;
this.$store.state.memberModalOptions.memberCount = data.memberCount;
this.$store.state.memberModalOptions.viewingMembers = data.viewingMembers;
this.$store.state.memberModalOptions.fetchMoreMembers = data.fetchMoreMembers;
this.$root.$emit('bv::show::modal', 'members-modal');
this.getMembers();
});
},
destroyed () {
this.$root.$off('habitica:show-member-modal');
},
computed: {
...mapState({user: 'user.data'}),
isLeader () {
if (!this.group || !this.group.leader) return false;
return this.user._id === this.group.leader || this.user._id === this.group.leader._id;
},
isAdmin () {
return Boolean(this.user.contributor.admin);
},
isLoadMoreAvailable () {
// Only available if the current length of `members` is less than the
// total size of the Group/Challenge
return this.members.length < this.$store.state.memberModalOptions.memberCount;
},
groupIsSubscribed () {
return this.group.purchased && this.group.purchased.active;
},
group () {
return this.$store.state.memberModalOptions.group;
},
groupId () {
return this.$store.state.memberModalOptions.groupId || this.group._id;
},
challengeId () {
return this.$store.state.memberModalOptions.challengeId;
},
sortedMembers () {
let sortedMembers = this.members;
if (!isEmpty(this.sortOption)) {
// Use the memberlist filtered by searchTerm
sortedMembers = orderBy(sortedMembers, [this.sortOption.value], [this.sortOption.direction]);
}
return sortedMembers;
},
},
watch: {
groupId () {
// @TOOD: We might not need this since groupId is computed now
this.getMembers();
},
challengeId () {
this.getMembers();
},
group () {
this.getMembers();
},
// Watches `searchTerm` and if present, performs a `searchMembers` action
// and usual `getMembers` otherwise
searchTerm () {
if (this.searchTerm) {
this.searchMembers(this.searchTerm);
} else {
this.getMembers();
}
},
},
methods: {
sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: member._id,
displayName: member.profile.name,
username: member.auth.local.username,
backer: member.backer,
contributor: member.contributor,
});
},
async searchMembers (searchTerm = '') {
this.members = await this.$store.state.memberModalOptions.fetchMoreMembers({
challengeId: this.challengeId,
groupId: this.groupId,
searchTerm,
includeAllPublicFields: true,
});
},
async getMembers () {
let groupId = this.groupId;
if (groupId && groupId !== 'challenge') {
let invites = await this.$store.dispatch('members:getGroupInvites', {
groupId,
includeAllPublicFields: true,
});
this.invites = invites;
}
this.members = this.$store.state.memberModalOptions.viewingMembers;
},
async clickMember (uid, forceShow) {
let user = this.$store.state.user.data;
if (user._id === uid && !forceShow) {
if (this.$route.name === 'tasks') {
this.$route.router.go('options.profile.avatar');
return;
}
this.$route.router.go('tasks');
return;
}
await this.$store.dispatch('members:selectMember', {
memberId: uid,
});
this.$root.$emit('bv::show::modal', 'members-modal');
},
async removeMember (member, index) {
this.memberToRemove = member;
this.memberToRemove.index = index;
this.$root.$emit('bv::show::modal', 'remove-member');
},
memberRemoved () {
this.members.splice(this.memberToRemove.index, 1);
this.group.memberCount -= 1;
this.memberToRemove = {};
},
async quickReply (uid) {
this.memberToReply = uid;
await this.$store.dispatch('members:selectMember', {
memberId: uid,
});
this.$root.$emit('bv::show::modal', 'private-message'); // MemberModalCtrl
},
async addManager (memberId) {
await this.$store.dispatch('guilds:addManager', {
groupId: this.groupId,
memberId,
});
alert(this.$t('managerAdded'));
},
async removeManager (memberId) {
await this.$store.dispatch('guilds:removeManager', {
groupId: this.groupId,
memberId,
});
alert(this.$t('managerRemoved'));
},
close () {
this.$root.$emit('bv::hide::modal', 'members-modal');
},
changeSortOption (e) {
this.sortOption.value = e.target.value;
this.sort();
},
changeSortDirection (e) {
this.sortOption.direction = e.target.value;
this.sort();
},
sort () {
this.sortDirty = true;
this.members = orderBy(this.members, [this.sortOption.value], [this.sortOption.direction]);
},
async applySortOptions () {
const settings = {
'party.order': this.sortOption.value,
'party.orderAscending': this.sortOption.direction,
};
await this.$store.dispatch('user:set', settings);
this.sortDirty = false;
},
async loadMoreMembers () {
const lastMember = this.members[this.members.length - 1];
if (!lastMember) return;
let newMembers = await this.$store.state.memberModalOptions.fetchMoreMembers({
challengeId: this.challengeId,
groupId: this.groupId,
lastMemberId: lastMember._id,
includeAllPublicFields: true,
});
this.members = this.members.concat(newMembers);
},
viewMembers () {
this.selectedPage = 'members';
},
viewInvites () {
this.selectedPage = 'invites';
},
async removeInvite (member, index) {
this.invites.splice(index, 1);
await this.$store.dispatch('members:removeMember', {
memberId: member._id,
groupId: this.groupId,
});
this.viewMembers();
},
async promoteToLeader (member) {
let groupData = Object.assign({}, this.group);
groupData.leader = member._id;
await this.$store.dispatch('guilds:update', {group: groupData});
alert(this.$t('leaderChanged'));
groupData.leader = member;
this.$root.$emit('updatedGroup', groupData);
},
viewProgress (member) {
this.$root.$emit('habitica:challenge:member-progress', {
progressMemberId: member._id,
});
},
shouldShowAddManager (memberId) {
if (!this.isLeader && !this.isAdmin) return false;
if (memberId === this.group.leader || memberId === this.group.leader._id) return false;
return this.groupIsSubscribed && !(this.group.managers && this.group.managers[memberId]);
},
shouldShowRemoveManager (memberId) {
if (!this.isLeader && !this.isAdmin) return false;
return this.group.managers && this.group.managers[memberId];
},
shouldShowLeaderFunctions (memberId) {
return !this.challengeId && (this.isLeader || this.isAdmin) && this.user._id !== memberId;
},
},
};
</script>
@@ -0,0 +1,144 @@
<template lang="pug">
.row
sidebar(v-on:search="updateSearch", v-on:filter="updateFilters")
.standard-page
.row
.col-md-8.text-left
h1.page-header(v-once) {{ $t('myGuilds') }}
h2(v-if='loading && guilds.length === 0') {{ $t('loading') }}
.col-4
button.btn.btn-secondary.create-group-button.float-right(@click='createGroup()')
.svg-icon.positive-icon(v-html="icons.positiveIcon")
span(v-once) {{$t('createGuild2')}}
// @TODO: Add when we implement recent activity .float-right
span.dropdown-label {{ $t('sortBy') }}
b-dropdown(:text="$t('sort')", right=true)
b-dropdown-item(v-for='sortOption in sortOptions', :key="sortOption.value", @click='sort(sortOption.value)') {{sortOption.text}}
.row
.no-guilds.text-center.col-md-6.offset-md-3(v-if='!loading && guilds.length === 0')
.svg-icon(v-html='icons.greyBadge')
h2(v-once) {{$t('noGuildsTitle')}}
p(v-once) {{$t('noGuildsParagraph1')}}
p(v-once) {{$t('noGuildsParagraph2')}}
.row
.no-guilds.text-center.col-md-6.offset-md-3(v-if='!loading && guilds.length > 0 && filteredGuilds.length === 0')
h2(v-once) {{$t('noGuildsMatchFilters')}}
.row
.col-md-12
public-guild-item(v-for="guild in filteredGuilds", :key='guild._id', :guild="guild", :display-gem-bank='true')
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.sort-select {
margin: 2em;
}
.positive-icon {
color: $green-10;
width: 10px;
display: inline-block;
margin-right: .5em;
}
.no-guilds {
color: $gray-200;
margin-top: 10em;
h2 {
color: $gray-200;
}
.svg-icon {
width: 88.7px;
margin: 1em auto;
}
}
</style>
<script>
import { mapState } from 'client/libs/store';
import groupUtilities from 'client/mixins/groupsUtilities';
import MugenScroll from 'vue-mugen-scroll';
import PublicGuildItem from './publicGuildItem';
import Sidebar from './sidebar';
import greyBadgeIcon from 'assets/svg/grey-badge.svg';
import positiveIcon from 'assets/svg/positive.svg';
export default {
mixins: [groupUtilities],
components: { PublicGuildItem, MugenScroll, Sidebar },
data () {
return {
icons: Object.freeze({
greyBadge: greyBadgeIcon,
positiveIcon,
}),
loading: false,
search: '',
filters: {},
sort: 'none',
sortOptions: [
{
text: this.$t('none'),
value: 'none',
},
{
text: this.$t('memberCount'),
value: 'member_count',
},
{
text: this.$t('recentActivity'),
value: 'recent_activity',
},
],
};
},
created () {
this.fetchGuilds();
},
computed: {
...mapState({
guilds: 'myGuilds',
}),
filteredGuilds () {
let search = this.search;
let filters = this.filters;
let user = this.$store.state.user.data;
let filterGuild = this.filterGuild;
return this.guilds.filter((guild) => {
if (guild.categories) {
guild.categorySlugs = guild.categories.map(cat => {
return cat.slug;
});
}
return filterGuild(guild, filters, search, user);
});
},
},
methods: {
updateSearch (eventData) {
this.search = eventData.searchTerm;
},
updateFilters (eventData) {
this.filters = eventData;
},
async fetchGuilds () {
this.loading = true;
await this.$store.dispatch('guilds:getMyGuilds');
this.loading = false;
},
createGroup () {
this.$store.state.editingGroup = {};
this.$root.$emit('bv::show::modal', 'guild-form');
},
},
};
</script>
@@ -0,0 +1,20 @@
<template lang="pug">
div
button.btn.btn-primary(b-btn, @click="$root.$emit('bv::show::modal','new-party-modal')") {{ $t('viewMembers') }}
b-modal#new-party-modal(:title="$t('createGuild')", size='lg')
.header-wrap(slot="modal-header")
.row
.col-6
h1(v-once) {{$t('members')}}
.col-6
button(type="button" aria-label="Close" class="close")
span(aria-hidden="true") ×
.row
.form-group.col-6
input.form-control.search(type="text", :placeholder="$t('search')", v-model='searchTerm')
.col-4.offset-2
span.dropdown-label {{ $t('sortBy') }}
b-dropdown(:text="$t('sort')", right=true)
b-dropdown-item(v-for='sortOption in sortOptions', @click='sort(sortOption.value)', :key='sortOption.value') {{sortOption.text}}
</template>
@@ -0,0 +1,93 @@
<template lang="pug">
b-modal#participant-list(size='md', :hide-footer='true')
.header-wrap(slot="modal-header")
.row
.col-6
h1(v-once) {{ $t('participantsTitle') }}
.col-6
button(type="button" aria-label="Close" class="close", @click='close()')
span(aria-hidden="true") ×
.row(v-for='member in participants')
.col-12.no-padding-left
member-details(:member='member')
.modal-footer
button.btn.btn-primary(@click='close()') {{ $t('close') }}
</template>
<style lang='scss'>
#participant-list {
.modal-header {
background-color: #edecee;
border-radius: 8px 8px 0 0;
box-shadow: 0 1px 2px 0 rgba(26, 24, 29, 0.24);
}
.modal-footer {
background-color: #edecee;
border-radius: 0 0 8px 8px;
}
.small-text, .character-name {
color: #878190;
}
.no-padding-left {
padding-left: 0;
}
.modal-body {
padding-left: 0;
padding-right: 0;
}
.member-details {
margin: 0;
}
}
</style>
<style lang='scss' scoped>
.header-wrap {
width: 100%;
}
h1 {
color: #4f2a93;
}
#participant-list_modal_body {
padding: 0;
max-height: 450px;
.member-details {
margin: 0;
}
}
</style>
<script>
import { mapGetters } from 'client/libs/store';
import MemberDetails from '../memberDetails';
export default {
props: ['group'],
components: {
MemberDetails,
},
computed: {
...mapGetters({
partyMembers: 'party:members',
}),
participants () {
let partyMembers = this.partyMembers || [];
return partyMembers.filter(member => this.group.quest.members[member._id] === true);
},
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'participant-list');
},
},
};
</script>
@@ -0,0 +1,195 @@
<template lang="pug">
router-link.card-link(:to="{ name: 'guild', params: { groupId: guild._id } }")
.card
.card-body
.row
.col-md-2.badge-column
.shield-wrap(:class="{gold: guild.memberCount >= 1000, silver: guild.memberCount >= 100 && guild.memberCount < 1000}")
.svg-icon.shield(v-html="icons.goldGuildBadge", v-if='guild.memberCount >= 1000')
.svg-icon.shield(v-html="icons.silverGuildBadgeIcon", v-if='guild.memberCount >= 100 && guild.memberCount < 1000')
.svg-icon.shield(v-html="icons.bronzeGuildBadgeIcon", v-if='guild.memberCount < 100')
.member-count {{ guild.memberCount | abbrNum }}
.col-md-10
.row
.col-md-8
router-link(:to="{ name: 'guild', params: { groupId: guild._id } }")
h3 {{ guild.name }}
p.summary(v-if='guild.summary') {{guild.summary.substr(0, MAX_SUMMARY_SIZE_FOR_GUILDS)}}
p.summary(v-else) {{ guild.name }}
.col-md-2.cta-container
button.btn.btn-danger(v-if='isMember && displayLeave' @click.prevent='leave()', v-once) {{ $t('leave') }}
button.btn.btn-success(v-if='!isMember' @click='join()', v-once) {{ $t('join') }}
div.item-with-icon.gem-bank(v-if='displayGemBank')
.svg-icon.gem(v-html="icons.gem")
span.count {{ guild.balance * 4 }}
div.guild-bank(v-if='displayGemBank', v-once) {{$t('guildBank')}}
.row
category-tags.col-md-12(:categories="guild.categories", :owner="isOwner", v-once)
span.recommend-text(v-if='showSuggested(guild._id)') {{$t('suggestedGroup')}}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.card-link {
color: #4E4A57 !important;
}
.card-link:hover {
text-decoration: none !important;
}
.card {
min-height: 160px;
border-radius: 4px;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba($black, 0.15), 0 1px 4px 0 rgba($black, 0.1);
margin-bottom: 1rem;
.recommend-text {
font-size: 12px;
font-style: italic;
line-height: 2;
color: $gray-300;
}
.cta-container {
margin: 0 auto;
button {
margin-top: 2.5em;
}
}
.item-with-icon {
.count {
font-size: 20px;
height: 37px;
width: 37px;
margin-left: .2em;
}
}
.shield {
width: 70px;
}
.gold {
color: #fdbb5a;
.member-count {
color: #fdbb5a;
}
}
.silver {
color: #c2c2c2;
.member-count {
color: #c2c2c2;
}
}
.badge-column {
display: flex;
align-items: center;
justify-content: center;
.shield-wrap {
display: inline-block;
height: 70px;
}
}
.guild-bank {
font-size: 12px;
line-height: 1.33;
color: $gray-300;
}
.member-count {
position: relative;
top: -3.7em;
font-size: 18px;
font-weight: bold;
line-height: 1.1;
text-align: center;
color: #b36213;
margin-top: 2.0em;
}
.gem-bank {
margin-top: 2em;
.gem {
width: 25px;
display: inline-block;
vertical-align: bottom;
margin-right: .8em;
}
}
}
</style>
<script>
import moment from 'moment';
import { mapState } from 'client/libs/store';
import categoryTags from '../categories/categoryTags';
import groupUtilities from 'client/mixins/groupsUtilities';
import markdown from 'client/directives/markdown';
import gemIcon from 'assets/svg/gem.svg';
import goldGuildBadgeIcon from 'assets/svg/gold-guild-badge-large.svg';
import silverGuildBadgeIcon from 'assets/svg/silver-guild-badge-large.svg';
import bronzeGuildBadgeIcon from 'assets/svg/bronze-guild-badge-large.svg';
import { MAX_SUMMARY_SIZE_FOR_GUILDS } from 'common/script/constants';
export default {
mixins: [groupUtilities],
directives: {
markdown,
},
props: ['guild', 'displayLeave', 'displayGemBank'],
components: {
categoryTags,
},
computed: {
...mapState({user: 'user.data'}),
isOwner () {
return this.guild.leader && this.guild.leader === this.user._id;
},
isMember () {
return this.isMemberOfGroup(this.user, this.guild);
},
},
data () {
return {
MAX_SUMMARY_SIZE_FOR_GUILDS,
icons: Object.freeze({
gem: gemIcon,
goldGuildBadge: goldGuildBadgeIcon,
silverGuildBadgeIcon,
bronzeGuildBadgeIcon,
}),
};
},
methods: {
showSuggested (guildId) {
let habiticaHelpingGuildId = '5481ccf3-5d2d-48a9-a871-70a7380cee5a';
let sixtyDaysAgoFromNow = moment().subtract(60, 'days');
let isUserNew = moment(this.user.auth.timestamps.created).isAfter(sixtyDaysAgoFromNow);
return guildId === habiticaHelpingGuildId && isUserNew;
},
async join () {
// @TODO: This needs to be in the notifications where users will now accept invites
if (this.guild.cancelledPlan && !confirm(window.env.t('aboutToJoinCancelledGroupPlan'))) {
return;
}
await this.$store.dispatch('guilds:join', {groupId: this.guild._id, type: 'guild'});
},
async leave () {
// @TODO: ask about challenges when we add challenges
await this.$store.dispatch('guilds:leave', {groupId: this.guild._id, type: 'myGuilds'});
},
},
};
</script>
@@ -0,0 +1,224 @@
<template lang="pug">
b-modal#quest-details(title="Empty", size='md', :hide-footer="true", :hide-header="true")
.left-panel.content
h3.text-center {{ $t('participantsTitle') }}
.row
.col-10.offset-1.text-center
span.description(v-once) {{ $t('participantDesc') }}
.row
.col-12.member(v-for='member in members')
strong(:class="{'declined-name': member.accepted === false}") {{member.name}}
.accepted.float-right(v-if='member.accepted === true') {{ $t('accepted') }}
.declined.float-right(v-if='member.accepted === false') {{ $t('declined') }}
.pending.float-right(v-if='member.accepted === null') {{ $t('pending') }}
div(v-if='questData')
questDialogContent(:item="questData")
div.text-center.actions(v-if='canEditQuest')
div
button.btn.btn-secondary(v-once, @click="questConfirm()") {{ $t('begin') }}
// @TODO don't allow the party leader to start the quest until the leader has accepted or rejected the invitation (users get confused and think "begin" means "join quest")
div
.cancel(v-once, @click="questCancel()") {{ $t('cancel') }}
.side-panel(v-if='questData')
questDialogDrops(:item="questData")
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
header {
background-color: $white !important;
border: none !important;
h5 {
text-indent: -99999px;
}
}
.quest-details {
margin: 0 auto;
text-align: left;
width: 180px;
}
.btn-primary {
margin: 1em 0;
}
.left-panel {
background: #4e4a57;
color: $white;
position: absolute;
height: 460px;
width: 320px;
top: 2.5em;
left: -22.8em;
z-index: -1;
padding: 2em;
overflow-y: auto;
h3 {
color: $white;
}
.selected .quest-wrapper {
border: solid 1.5px #9a62ff;
}
.quest-wrapper:hover {
cursor: pointer;
}
.quest-col .quest-wrapper {
background: $white;
padding: .2em;
margin-bottom: 1em;
border-radius: 3px;
}
.description {
text-align: center;
color: #a5a1ac;
font-size: 12px;
}
}
.side-panel {
position: absolute;
right: -350px;
top: 25px;
border-radius: 8px;
background-color: $gray-600;
box-shadow: 0 2px 16px 0 rgba(26, 24, 29, 0.32);
display: flex;
align-items: center;
flex-direction: column;
width: 364px;
z-index: -1;
height: 93%;
}
.member {
padding: 1em .5em;
border-top: 1px solid #686274;
.declined-name {
color: #878190;
}
.accepted {
color: #1ed3a0;
}
.declined {
color: #f19595;
}
.pending {
color: #c3c0c7;
}
}
.actions {
padding-top: 2em;
padding-bottom: 2em;
.cancel {
color: #f74e52;
margin-top: 3em;
}
.cancel:hover {
cursor: pointer;
}
}
</style>
<script>
import { mapState, mapGetters } from 'client/libs/store';
import quests from 'common/script/content/quests';
import copyIcon from 'assets/svg/copy.svg';
import greyBadgeIcon from 'assets/svg/grey-badge.svg';
import qrCodeIcon from 'assets/svg/qrCode.svg';
import facebookIcon from 'assets/svg/facebook.svg';
import twitterIcon from 'assets/svg/twitter.svg';
import starIcon from 'assets/svg/star.svg';
import goldIcon from 'assets/svg/gold.svg';
import difficultyStarIcon from 'assets/svg/difficulty-star.svg';
import questDialogDrops from '../shops/quests/questDialogDrops';
import questDialogContent from '../shops/quests/questDialogContent';
export default {
props: ['group'],
components: {
questDialogDrops,
questDialogContent,
},
data () {
return {
loading: false,
selectedQuest: {},
icons: Object.freeze({
copy: copyIcon,
greyBadge: greyBadgeIcon,
qrCode: qrCodeIcon,
facebook: facebookIcon,
twitter: twitterIcon,
starIcon,
goldIcon,
difficultyStarIcon,
}),
};
},
computed: {
...mapState({
user: 'user.data',
}),
...mapGetters({
partyMembers: 'party:members',
}),
questData () {
return quests.quests[this.group.quest.key];
},
members () {
let partyMembers = this.partyMembers || [];
return partyMembers.map(member => {
return {
name: member.profile.name,
accepted: this.group.quest.members[member._id],
};
});
},
canEditQuest () {
if (!this.group.quest) return false;
let isQuestLeader = this.group.quest.leader === this.user._id;
let isPartyLeader = this.group.leader._id === this.user._id;
return isQuestLeader || isPartyLeader;
},
},
methods: {
async questConfirm () {
let count = 0;
for (let uuid in this.group.quest.members) {
if (this.group.quest.members[uuid]) count += 1;
}
if (!confirm(this.$t('questConfirm', { questmembers: count, totalmembers: this.group.memberCount}))) return;
this.questForceStart();
},
async questForceStart () {
let quest = await this.$store.dispatch('quests:sendAction', {groupId: this.group._id, action: 'quests/force-start'});
this.group.quest = quest;
this.close();
},
async questCancel () {
if (!confirm(this.$t('sureCancel'))) return;
let quest = await this.$store.dispatch('quests:sendAction', {groupId: this.group._id, action: 'quests/cancel'});
this.group.quest = quest;
this.close();
},
close () {
this.$root.$emit('bv::hide::modal', 'quest-details');
},
},
};
</script>
@@ -0,0 +1,294 @@
<template lang="pug">
sidebar-section(:title="$t('questDetailsTitle')")
.row.no-quest-section(v-if='!onPendingQuest && !onActiveQuest')
.col-12.text-center
.svg-icon(v-html="icons.questIcon")
h4(v-once) {{ $t('youAreNotOnQuest') }}
p(v-once) {{ $t('questDescription') }}
button.btn.btn-secondary(v-once, @click="openStartQuestModal()") {{ $t('startAQuest') }}
.row.quest-active-section(v-if='onPendingQuest && !onActiveQuest')
.col-2
.quest(:class='`inventory_quest_scroll_${questData.key}`')
.col-6.titles
strong {{ questData.text() }}
p {{acceptedCount}} / {{group.memberCount}}
.col-4
button.btn.btn-secondary(@click="openQuestDetails()") {{ $t('details') }}
.row.quest-active-section.quest-invite(v-if='user.party.quest && user.party.quest.RSVPNeeded')
span {{ $t('wouldYouParticipate') }}
button.btn.btn-primary.accept(@click='questAccept(group._id)') {{$t('accept')}}
button.btn.btn-primary.reject(@click='questReject(group._id)') {{$t('reject')}}
.row.quest-active-section(v-if='!onPendingQuest && onActiveQuest')
.col-12.text-center
.quest-boss(:class="'quest_' + questData.key")
h3(v-once) {{ questData.text() }}
.quest-box
.collect-info(v-if='questData.collect')
.row
.col-12
a.float-right(@click="openParticipantList()") {{ $t('participantsTitle') }}
.row(v-for='(value, key) in questData.collect')
.col-2
div(:class="'quest_' + questData.key + '_' + key")
.col-10
strong {{value.text()}}
.grey-progress-bar
.collect-progress-bar(:style="{width: (group.quest.progress.collect[key] / value.count) * 100 + '%'}")
strong {{group.quest.progress.collect[key]}} / {{value.count}}
div.text-right(v-if='userIsOnQuest') {{parseFloat(user.party.quest.progress.collectedItems) || 0}} items found
.boss-info(v-if='questData.boss')
.row
.col-6
h4.float-left(v-once) {{ questData.boss.name() }}
.col-6
a.float-right(@click="openParticipantList()") {{ $t('participantsTitle') }}
.row
.col-12
.grey-progress-bar
.boss-health-bar(:style="{width: bossHpPercent + '%'}")
.row.boss-details
.col-6
span.float-left
| {{ Math.ceil(parseFloat(group.quest.progress.hp) * 100) / 100 }} / {{ parseFloat(questData.boss.hp).toFixed(2) }}
// current boss hp uses ceil so you don't underestimate damage needed to end quest
.col-6(v-if='userIsOnQuest')
// @TODO: Why do we not sync quest progress on the group doc? Each user could have different progress.
span.float-right {{ user.party.quest.progress.up | floor(10) }} {{ $t('pendingDamageLabel') }}
// player's pending damage uses floor so you don't overestimate damage you've already done
.row.rage-bar-row(v-if='questData.boss.rage')
.col-12
.grey-progress-bar
.boss-health-bar.rage-bar(:style="{width: (group.quest.progress.rage / questData.boss.rage.value) * 100 + '%'}")
.row.boss-details.rage-details(v-if='questData.boss.rage')
.col-6
span.float-left {{ $t('rage') }} {{ parseFloat(group.quest.progress.rage).toFixed(2) }} / {{ questData.boss.rage.value }}
button.btn.btn-secondary(v-once, @click="questAbort()", v-if='canEditQuest') {{ $t('abort') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.svg-icon {
height: 25px;
width: 25px;
}
.quest-boss {
margin: 0 auto;
}
.boss-health-bar {
width: 80%;
background-color: red;
height: 15px;
margin-bottom: .5em;
}
.rage-details {
margin-bottom: 1em;
}
.boss-health-bar.rage-bar {
margin-top: 1em;
background-color: orange;
}
.grey-progress-bar {
width: 100%;
height: 15px;
background-color: #e1e0e3;
}
.collect-progress-bar {
background-color: #24cc8f;
height: 15px;
max-width: 100%;
}
.no-quest-section {
padding: 2em;
color: $gray-300;
h4 {
color: $gray-300;
}
p {
margin-bottom: 2em;
}
.svg-icon {
height: 30px;
width: 30px;
margin: 0 auto;
margin-bottom: 2em;
}
}
.quest-active-section {
.titles {
padding-top: .5em;
}
.quest-box {
background-image: url('~client/assets/svg/for-css/quest-border.svg');
background-size: 100% 100%;
width: 100%;
padding: .5em;
margin-bottom: 1em;
a {
font-family: 'Roboto Condensed', sans-serif;
font-weight: bold;
color: $gray-10;
}
svg: {
width: 100%;
height: 100%;
}
}
.boss-info, .collect-info {
width: 90%;
margin: 0 auto;
text-align: left;
}
}
.quest-invite {
background-color: #2995cd;
color: #fff;
padding: 1em;
span {
margin-top: .3em;
font-size: 14px;
font-weight: bold;
}
.accept, .reject {
padding: .2em 1em;
font-size: 12px;
height: 24px;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
}
.accept {
background-color: #24cc8f;
margin-left: 4em;
margin-right: .5em;
}
.reject {
border-radius: 2px;
background-color: #f74e52;
}
}
</style>
<script>
import { mapState } from 'client/libs/store';
import quests from 'common/script/content/quests';
import percent from 'common/script/libs/percent';
import sidebarSection from '../sidebarSection';
import questIcon from 'assets/svg/quest.svg';
export default {
props: ['group'],
components: {
sidebarSection,
},
data () {
return {
icons: Object.freeze({
questIcon,
}),
};
},
computed: {
...mapState({user: 'user.data'}),
userIsOnQuest () {
if (!this.group.quest || !this.group.quest.members) return false;
return Boolean(this.group.quest.members[this.user._id]);
},
onPendingQuest () {
return Boolean(this.group.quest.key) && !this.group.quest.active;
},
onActiveQuest () {
return this.group.quest.active;
},
bossHpPercent () {
return percent(this.group.quest.progress.hp, this.questData.boss.hp);
},
questData () {
if (!this.group.quest) return {};
return quests.quests[this.group.quest.key];
},
canEditQuest () {
if (!this.group.quest) return false;
let isQuestLeader = this.group.quest.leader === this.user._id;
let isPartyLeader = this.group.leader._id === this.user._id;
return isQuestLeader || isPartyLeader;
},
isMemberOfPendingQuest () {
let userid = this.user._id;
let group = this.group;
if (!group.quest || !group.quest.members) return false;
if (group.quest.active) return false; // quest is started, not pending
return userid in group.quest.members && group.quest.members[userid] !== false;
},
isMemberOfRunningQuest () {
let userid = this.user._id;
let group = this.group;
if (!group.quest || !group.quest.members) return false;
if (!group.quest.active) return false; // quest is pending, not started
return group.quest.members[userid];
},
acceptedCount () {
let count = 0;
if (!this.group || !this.group.quest) return count;
for (let uuid in this.group.quest.members) {
if (this.group.quest.members[uuid]) count += 1;
}
return count;
},
},
methods: {
openStartQuestModal () {
this.$root.$emit('bv::show::modal', 'start-quest-modal');
},
openQuestDetails () {
this.$root.$emit('bv::show::modal', 'quest-details');
},
openParticipantList () {
this.$root.$emit('bv::show::modal', 'participant-list');
},
async questAbort () {
if (!confirm(this.$t('sureAbort'))) return;
if (!confirm(this.$t('doubleSureAbort'))) return;
let quest = await this.$store.dispatch('quests:sendAction', {groupId: this.group._id, action: 'quests/abort'});
this.group.quest = quest;
},
async questLeave () {
if (!confirm(this.$t('sureLeave'))) return;
let quest = await this.$store.dispatch('quests:sendAction', {groupId: this.group._id, action: 'quests/leave'});
this.group.quest = quest;
},
async questAccept (partyId) {
let quest = await this.$store.dispatch('quests:sendAction', {groupId: partyId, action: 'quests/accept'});
this.user.party.quest = quest;
},
async questReject (partyId) {
let quest = await this.$store.dispatch('quests:sendAction', {groupId: partyId, action: 'quests/reject'});
this.user.party.quest = quest;
},
},
};
</script>
@@ -0,0 +1,157 @@
<template lang="pug">
.standard-sidebar.d-none.d-sm-block
.form-group
input.form-control.search(type="text", :placeholder="$t('search')", v-model='searchTerm')
form
h2(v-once) {{ $t('filter') }}
.form-group
h3 {{ $t('category') }}
.form-check(
v-for="group in categoryOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="categoryFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
.form-group
h3 {{ $t('role') }}
.form-check(
v-for="group in roleOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="roleFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
.form-group
h3 {{ $t('guildSize') }}
.form-check(
v-for="group in guildSizeOptions",
:key="group.key",
)
.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="guildSizeFilters", :id="group.key")
label.custom-control-label(v-once, :for="group.key") {{ $t(group.label) }}
</template>
<script>
import throttle from 'lodash/throttle';
export default {
data () {
return {
categoryFilters: [],
categoryOptions: [
{
label: 'habitica_official',
key: 'habitica_official',
},
{
label: 'academics',
key: 'academics',
},
{
label: 'advocacy_causes',
key: 'advocacy_causes',
},
{
label: 'creativity',
key: 'creativity',
},
{
label: 'entertainment',
key: 'entertainment',
},
{
label: 'finance',
key: 'finance',
},
{
label: 'health_fitness',
key: 'health_fitness',
},
{
label: 'hobbies_occupations',
key: 'hobbies_occupations',
},
{
label: 'location_based',
key: 'location_based',
},
{
label: 'mental_health',
key: 'mental_health',
},
{
label: 'getting_organized',
key: 'getting_organized',
},
{
label: 'recovery_support_groups',
key: 'recovery_support_groups',
},
{
label: 'spirituality',
key: 'spirituality',
},
{
label: 'time_management',
key: 'time_management',
},
],
roleFilters: [],
roleOptions: [
{
label: 'guildLeader',
key: 'guild_leader',
},
{
label: 'member',
key: 'member',
},
],
guildSizeFilters: [],
guildSizeOptions: [
{
label: 'goldTier',
key: 'gold_tier',
},
{
label: 'silverTier',
key: 'silver_tier',
},
{
label: 'bronzeTier',
key: 'bronze_tier',
},
],
searchTerm: '',
};
},
watch: {
categoryFilters: function categoryFilters () {
this.emitFilters();
},
roleFilters: function roleFilters () {
this.emitFilters();
},
guildSizeFilters: function guildSizeFilters () {
this.emitFilters();
},
searchTerm: throttle(function searchTerm (newSearch) {
this.$emit('search', {
searchTerm: newSearch,
});
}, 1000),
},
methods: {
emitFilters () {
this.$emit('filter', {
categories: this.categoryFilters,
roles: this.roleFilters,
guildSize: this.guildSizeFilters,
});
},
},
};
</script>
@@ -0,0 +1,197 @@
<template lang="pug">
b-modal#start-quest-modal(title="Empty", size='md', :hide-footer="true", :hide-header="true")
.left-panel.content
h3.text-center Quests
.row
.col-4.quest-col(
v-for='(value, key, index) in user.items.quests',
@click='selectQuest({key})',
:class="{selected: key === selectedQuest}", v-if='value > 0')
.quest-wrapper
b-popover(
:target="`inventory_quest_scroll_${key}`"
placement="top"
triggers="hover")
h4.popover-content-title {{ quests.quests[key].text() }}
questInfo(:quest="quests.quests[key]")
.quest(:class="`inventory_quest_scroll_${key}`", :id="`inventory_quest_scroll_${key}`")
.row
.col-10.offset-1.text-center
span.description(v-once) {{ $t('noQuestToStart') }}
div(v-if='questData')
questDialogContent(:item="questData")
div.text-center
button.btn.btn-primary(@click='questInit()', :disabled="!Boolean(selectedQuest) || loading") {{$t('inviteToPartyOrQuest')}}
div.text-center
p {{$t('inviteInformation')}}
.side-panel(v-if='questData')
questDialogDrops(:item="questData")
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
header {
background-color: $white !important;
border: none !important;
h5 {
text-indent: -99999px;
}
}
.quest-details {
margin: 0 auto;
text-align: left;
width: 180px;
}
.btn-primary {
margin: 1em 0;
}
.left-panel {
background: #4e4a57;
color: $white;
position: absolute;
height: 460px;
width: 320px;
top: 2.5em;
left: -23em;
z-index: -1;
padding: 2em;
overflow-y: auto;
h3 {
color: $white;
}
.selected .quest-wrapper {
border: solid 1.5px #9a62ff;
}
.quest-wrapper:hover {
cursor: pointer;
}
.quest-col .quest-wrapper {
background: $white;
padding: .2em;
margin-bottom: 1em;
border-radius: 3px;
}
.description {
text-align: center;
color: #a5a1ac;
font-size: 12px;
}
}
.side-panel {
position: absolute;
right: -350px;
top: 25px;
border-radius: 8px;
background-color: $gray-600;
box-shadow: 0 2px 16px 0 rgba(26, 24, 29, 0.32);
display: flex;
align-items: center;
flex-direction: column;
width: 364px;
z-index: -1;
height: 93%;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import quests from 'common/script/content/quests';
import copyIcon from 'assets/svg/copy.svg';
import greyBadgeIcon from 'assets/svg/grey-badge.svg';
import qrCodeIcon from 'assets/svg/qrCode.svg';
import facebookIcon from 'assets/svg/facebook.svg';
import twitterIcon from 'assets/svg/twitter.svg';
import starIcon from 'assets/svg/star.svg';
import goldIcon from 'assets/svg/gold.svg';
import difficultyStarIcon from 'assets/svg/difficulty-star.svg';
import questDialogDrops from '../shops/quests/questDialogDrops';
import questDialogContent from '../shops/quests/questDialogContent';
import QuestInfo from '../shops/quests/questInfo';
export default {
props: ['group'],
components: {
questDialogDrops,
questDialogContent,
QuestInfo,
},
data () {
return {
loading: false,
selectedQuest: {},
icons: Object.freeze({
copy: copyIcon,
greyBadge: greyBadgeIcon,
qrCode: qrCodeIcon,
facebook: facebookIcon,
twitter: twitterIcon,
starIcon,
goldIcon,
difficultyStarIcon,
}),
shareUserIdShown: false,
quests,
};
},
mounted () {
const userQuests = this.user.items.quests;
for (const key in userQuests) {
if (userQuests[key] > 0) {
this.selectedQuest = key;
break;
}
}
this.$root.$on('selectQuest', this.selectQuest);
},
destroyed () {
this.$root.$off('selectQuest', this.selectQuest);
},
computed: {
...mapState({user: 'user.data'}),
questData () {
return quests.quests[this.selectedQuest];
},
},
methods: {
selectQuest (quest) {
this.selectedQuest = quest.key;
},
async questInit () {
this.loading = true;
Analytics.updateUser({
partyID: this.group._id,
partySize: this.group.memberCount,
});
let groupId = this.group._id || this.user.party._id;
const key = this.selectedQuest;
try {
const response = await this.$store.dispatch('guilds:inviteToQuest', {groupId, key});
const quest = response.data.data;
if (this.$store.state.party.data) this.$store.state.party.data.quest = quest;
} finally {
this.loading = false;
}
this.$root.$emit('bv::hide::modal', 'start-quest-modal');
},
},
};
</script>
@@ -0,0 +1,550 @@
<template lang="pug">
.row
world-boss-info-modal
world-boss-rage-modal
.col-12.col-sm-8.clearfix.standard-page
.row
.col-6.title-details
h1(v-once) {{ $t('welcomeToTavern') }}
chat(
:label="$t('tavernChat')",
:group="group",
:placeholder="$t('tavernCommunityGuidelinesPlaceholder')",
@fetchRecentMessages="fetchRecentMessages()"
)
.col-12.col-sm-4.sidebar
.section
.grassy-meadow-backdrop
.daniel_front
.boss-section
.world-boss(v-if='group && group.quest && group.quest.active', :style="{background: questData.colors.dark, 'border-color': questData.colors.extralight, 'outline-color': questData.colors.light}")
.corner-decoration(:style="{top: '-2px', right: '-2px'}")
.corner-decoration(:style="{top: '-2px', left: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', right: '-2px'}")
.corner-decoration(:style="{bottom: '-2px', left: '-2px'}")
.text-center.float-bar.d-flex.align-items-center
span.diamond
span.strong.reduce(:style="{background: questData.colors.dark}") {{ $t('worldBossEvent') }}
span.diamond
.boss-gradient.pb-3.pt-3
p.text-center.reduce(:style="{color: questData.colors.extralight}") {{ $t(`${questData.key}ArtCredit`) }}
.quest-boss(:class="'background_' + questData.key")
.quest-boss(:class="'quest_' + questData.key")
.quest-boss(:class="'phobia_' + questData.key", :style="{display: 'none'}")
.p-3
.row.d-flex.align-items-center.mb-2
.col-sm-6
strong.float-left {{ questData.boss.name() }}
.col-sm-6
span.d-flex.float-right
.svg-icon.boss-icon(v-html="icons.swordIcon")
span.ml-1.reduce(:style="{color: questData.colors.extralight}") {{ $t('pendingDamage', {damage: pendingDamage()}) }}
.grey-progress-bar.mb-1
.boss-health-bar(:style="{width: (group.quest.progress.hp / questData.boss.hp) * 100 + '%'}")
span.d-flex.align-items-center
.svg-icon.boss-icon(v-html="icons.healthIcon")
span.reduce.ml-1.pt-1 {{ $t('bossHealth', {currentHealth: bossCurrentHealth(), maxHealth: questData.boss.hp.toLocaleString()}) }}
.mt-3.mb-2
strong.mr-1 {{ $t('rageAttack') }}
span {{ questData.boss.rage.title() }}
.grey-progress-bar.mb-1
.boss-health-bar.rage-bar(:style="{width: (group.quest.progress.rage / questData.boss.rage.value) * 100 + '%'}")
span.d-flex.align-items-center
.svg-icon.boss-icon(v-html="icons.rageIcon")
span.reduce.ml-1.pt-1 {{ $t('bossRage', {currentRage: bossCurrentRage(), maxRage: questData.boss.rage.value.toLocaleString()}) }}
.row.d-flex.align-items-center.mb-2.mt-2
.col-sm-4.d-flex
strong.mr-2 {{ $t('rageStrikes') }}
.svg-icon.boss-icon.information-icon.m-auto(v-html="icons.informationIcon", v-b-tooltip.hover.top="questData.boss.rage.description()")
.col-sm-8.d-flex.align-items-center.justify-content-center
.m-auto(@click="showWorldBossRage('seasonalShop')")
img.rage-strike(src="~assets/images/world-boss/rage_strike@2x.png", v-if="!group.quest.extra.worldDmg.seasonalShop")
img.rage-strike-active(src="~assets/images/world-boss/rage_strike-seasonalShop@2x.png", v-if="group.quest.extra.worldDmg.seasonalShop")
.m-auto(@click="showWorldBossRage('market')")
img.rage-strike(src="~assets/images/world-boss/rage_strike@2x.png", v-if="!group.quest.extra.worldDmg.market")
img.rage-strike-active(src="~assets/images/world-boss/rage_strike-market@2x.png", v-if="group.quest.extra.worldDmg.market")
.m-auto(@click="showWorldBossRage('quests')")
img.rage-strike(src="~assets/images/world-boss/rage_strike@2x.png", v-if="!group.quest.extra.worldDmg.quests")
img.rage-strike-active(src="~assets/images/world-boss/rage_strike-quests@2x.png", v-if="group.quest.extra.worldDmg.quests")
.boss-description.p-3(:style="{'border-color': questData.colors.extralight}", @click="sections.worldBoss = !sections.worldBoss")
strong.float-left {{ $t('worldBossDescription') }}
.float-right
.toggle-down(v-if="!sections.worldBoss")
.svg-icon.boss-icon(v-html="icons.chevronIcon")
.toggle-up(v-if="sections.worldBoss")
.svg-icon.boss-icon.reverse(v-html="icons.chevronIcon")
.mt-3(v-if="sections.worldBoss", v-html="questData.notes()")
// .text-center.mt-4
.world-boss-info-button(@click="showWorldBossInfo()") {{$t('whatIsWorldBoss') }}
.sleep.below-header-sections
strong(v-once) {{ $t('sleepDescription') }}
ul
li(v-once) {{ $t('sleepBullet1') }}
li(v-once) {{ $t('sleepBullet2') }}
li(v-once) {{ $t('sleepBullet3') }}
li(v-once) {{ $t('sleepBullet4') }}
button.btn.btn-secondary.pause-button(v-if='!user.preferences.sleep', @click='toggleSleep()', v-once) {{ $t('pauseDailies') }}
button.btn.btn-secondary.pause-button(v-if='user.preferences.sleep', @click='toggleSleep()', v-once) {{ $t('unpauseDailies') }}
.px-3
sidebar-section(:title="$t('staffAndModerators')")
.row
.col-4.staff(v-for='user in staff', :class='{staff: user.type === "Staff", moderator: user.type === "Moderator", bailey: user.name === "It\'s Bailey"}')
div
router-link.title(:to="{'name': 'userProfile', 'params': {'userId': user.uuid}}") {{user.name}}
.svg-icon.staff-icon(v-html="icons.tierStaff", v-if='user.type === "Staff"')
.svg-icon.mod-icon(v-html="icons.tierMod", v-if='user.type === "Moderator" && user.name !== "It\'s Bailey"')
.svg-icon.npc-icon(v-html="icons.tierNPC", v-if='user.name === "It\'s Bailey"')
.type {{user.type}}
sidebar-section(:title="$t('helpfulLinks')")
ul
li
a(href='', @click.prevent='modForm()') {{ $t('contactForm') }}
li
router-link(to='/static/community-guidelines', v-once) {{ $t('communityGuidelinesLink') }}
li
router-link(to="/groups/guild/f2db2a7f-13c5-454d-b3ee-ea1f5089e601") {{ $t('lookingForGroup') }}
li
router-link(to='/static/faq', v-once) {{ $t('faq') }}
li
a(href='', v-html="$t('glossary')")
li
a(href='http://habitica.fandom.com/wiki/Habitica_Wiki' target='_blank', v-once) {{ $t('wiki') }}
li
a(href='https://oldgods.net/habitrpg/habitrpg_user_data_display.html', target='_blank', v-once) {{ $t('dataDisplayTool') }}
li
router-link(to="/groups/guild/a29da26b-37de-4a71-b0c6-48e72a900dac") {{ $t('reportProblem') }}
li
a(href='https://trello.com/c/odmhIqyW/440-read-first-table-of-contents', target='_blank', v-once) {{ $t('requestFeature') }}
li
a(href='', v-html="$t('communityForum')")
li
router-link(to="/groups/guild/5481ccf3-5d2d-48a9-a871-70a7380cee5a") {{ $t('askQuestionGuild') }}
sidebar-section(:title="$t('playerTiers')")
.row
.col-12
p(v-once) {{ $t('playerTiersDesc') }}
ul.tier-list
li.tier1(v-once)
| {{ $t('tier1') }}
.svg-icon.tier1-icon(v-html="icons.tier1")
li.tier2(v-once)
| {{ $t('tier2') }}
.svg-icon.tier2-icon(v-html="icons.tier2")
li.tier3(v-once)
| {{ $t('tier3') }}
.svg-icon.tier3-icon(v-html="icons.tier3")
li.tier4(v-once)
| {{ $t('tier4') }}
.svg-icon.tier4-icon(v-html="icons.tier4")
li.tier5(v-once)
| {{ $t('tier5') }}
.svg-icon.tier5-icon(v-html="icons.tier5")
li.tier6(v-once)
| {{ $t('tier6') }}
.svg-icon.tier6-icon(v-html="icons.tier6")
li.tier7(v-once)
| {{ $t('tier7') }}
.svg-icon.tier7-icon(v-html="icons.tier7")
li.moderator(v-once)
| {{ $t('tierModerator') }}
.svg-icon.mod-icon(v-html="icons.tierMod")
li.staff(v-once)
| {{ $t('tierStaff') }}
.svg-icon.staff-icon(v-html="icons.tierStaff")
li.npc(v-once)
| {{ $t('tierNPC') }}
.svg-icon.npc-icon(v-html="icons.tierNPC")
</template>
<style lang='scss' scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/variables.scss';
h1 {
color: $purple-200;
}
.sidebar {
background-color: $gray-600;
padding: 0em;
.below-header-sections {
padding: 1em 1.75em 1em 1.75em;
}
}
.pause-button {
background-color: #ffb445 !important;
color: $white;
width: 100%;
}
.grassy-meadow-backdrop {
background-image: url('~assets/images/npc/#{$npc_tavern_flavor}/tavern_background.png');
background-repeat: repeat-x;
width: 100%;
height: 246px;
}
.daniel_front {
background-image: url('~assets/images/npc/#{$npc_tavern_flavor}/tavern_npc.png');
height: 246px;
width: 471px;
background-repeat: no-repeat;
margin: 0 auto;
}
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
.tier1-icon, .tier2-icon {
width: 11px;
}
.tier5-icon, .tier6-icon {
width: 8px;
}
.tier7-icon {
width: 12px;
}
.mod-icon {
width: 13px;
}
.npc-icon {
width: 8px;
}
.boss-icon {
width: 16px;
margin-top: .1em;
margin-left: 0;
}
.boss-icon-large {
width: 48px;
}
.staff {
margin-bottom: 1em;
.staff-icon {
width: 11px;
}
.title {
color: #6133b4;
font-weight: bold;
display: inline-block;
}
}
.tier-list {
list-style-type: none;
padding: 0;
width: 98%;
li {
border-radius: 2px;
background-color: #edecee;
border: solid 1px #c3c0c7;
text-align: center;
padding: 1em;
margin-bottom: 1em;
font-weight: bold;
}
.tier1 {
color: #c42870;
}
.tier2 {
color: #b01515;
}
.tier3 {
color: #d70e14;
}
.tier4 {
color: #c24d00;
}
.tier5 {
color: #9e650f;
}
.tier6 {
color: #2b8363;
}
.tier7 {
color: #167e87;
}
.tier8, .moderator {
color: #277eab;
}
.tier9, .staff {
color: #6133b4;
}
.npc {
color: $black;
}
}
.staff .title {
color: #6133b4;
}
.moderator .title {
color: #277eab;
}
.bailey .title {
color: $black;
}
.boss-section {
padding: 1.75em;
}
.world-boss {
color: $white;
border-style: solid;
border-width: 2px;
outline-style: solid;
outline-width: 2px;
margin: 2px;
position: relative;
}
.quest-boss {
margin: 1em auto;
}
.grey-progress-bar {
width: 100%;
height: 15px;
background-color: rgba(255, 255, 255, 0.24);
border-radius: 2px;
}
.boss-health-bar {
width: 80%;
height: 15px;
margin-bottom: .5em;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
background-color: #f74e52;
}
.boss-health-bar.rage-bar {
background-color: #ff944c;
}
.boss-gradient {
background-image: linear-gradient(to bottom, #401f2a, #931f4d);
margin-top: -1.4em;
}
.boss-description {
border-top: 1px solid;
margin-left: -16px;
margin-right: -16px;
padding: .25em 0 0 .25em;
}
.float-bar {
position: relative;
top: -16px;
width: 162px;
height: 28px;
border-radius: 2px;
background-color: inherit;
margin: auto;
}
.corner-decoration {
position: absolute;
width: 6px;
height: 6px;
background-color: inherit;
border: inherit;
outline: inherit;
}
.reverse {
transform: rotate(180deg);
}
.diamond {
margin: auto;
display: inline-block;
width: 6px;
height: 6px;
-webkit-transform: rotate(-45deg);
transform: rotate(-45deg);
background-color: #dc4069;
border: solid 2px #931f4d;
}
.reduce {
font-size: 12px;
}
.rage-strike {
max-width: 50px;
height: auto;
}
.rage-strike-active {
max-width: 75px;
height: auto;
cursor: pointer;
}
.world-boss-info-button {
width: 100%;
background-color: $gray-500;
border-radius: 2px;
font-size: 14px;
color: $blue-10;
padding: 1em;
cursor: pointer;
}
</style>
<script>
import { mapState } from 'client/libs/store';
import { goToModForm } from 'client/libs/modform';
import { TAVERN_ID } from '../../../common/script/constants';
import worldBossInfoModal from '../world-boss/worldBossInfoModal';
import worldBossRageModal from '../world-boss/worldBossRageModal';
import sidebarSection from '../sidebarSection';
import chat from './chat';
import challengeIcon from 'assets/svg/challenge.svg';
import chevronIcon from 'assets/svg/chevron-red.svg';
import gemIcon from 'assets/svg/gem.svg';
import healthIcon from 'assets/svg/health.svg';
import informationIconRed from 'assets/svg/information-red.svg';
import questBackground from 'assets/svg/quest-background-border.svg';
import rageIcon from 'assets/svg/rage.svg';
import swordIcon from 'assets/svg/sword.svg';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tierMod from 'assets/svg/tier-mod.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
import tierStaff from 'assets/svg/tier-staff.svg';
import quests from 'common/script/content/quests';
import staffList from '../../libs/staffList';
export default {
components: {
worldBossInfoModal,
worldBossRageModal,
sidebarSection,
chat,
},
data () {
return {
groupId: TAVERN_ID,
icons: Object.freeze({
challengeIcon,
chevronIcon,
gem: gemIcon,
healthIcon,
informationIcon: informationIconRed,
questBackground,
rageIcon,
swordIcon,
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tierMod,
tierNPC,
tierStaff,
}),
group: {
chat: [],
},
sections: {
worldBoss: true,
},
staff: staffList,
};
},
computed: {
...mapState({user: 'user.data'}),
questData () {
if (!this.group.quest) return {};
return quests.quests[this.group.quest.key];
},
},
async mounted () {
this.group = await this.$store.dispatch('guilds:getGroup', {groupId: TAVERN_ID});
},
methods: {
modForm () {
goToModForm(this.user);
},
toggleSleep () {
this.$store.dispatch('user:sleep');
},
pendingDamage () {
if (!this.user.party.quest.progress.up) return 0;
return this.$options.filters.floor(this.user.party.quest.progress.up, 10);
// keep user's pending damage consistent with how it's displayed on the party page
},
bossCurrentHealth () {
if (!this.group.quest.progress.hp) return 0;
return Math.ceil(parseFloat(this.group.quest.progress.hp)).toLocaleString();
},
bossCurrentRage () {
if (!this.group.quest.progress.hp) return 0;
return Math.floor(parseFloat(this.group.quest.progress.rage)).toLocaleString();
},
showWorldBossInfo () {
this.$root.$emit('bv::show::modal', 'world-boss-info');
},
showWorldBossRage (npc) {
if (this.group.quest.extra.worldDmg[npc]) {
this.$store.state.rageModalOptions.npc = npc;
this.$root.$emit('bv::show::modal', 'world-boss-rage');
}
},
async fetchRecentMessages () {
this.group = await this.$store.dispatch('guilds:getGroup', {groupId: TAVERN_ID});
},
},
};
</script>
@@ -0,0 +1,210 @@
<template lang="pug">
.row.standard-page
small.muted(v-html="$t('blurbHallContributors')")
.well
div(v-if='user.contributor.admin')
h2 Reward User
.row
.form.col-6(v-if='!hero.profile')
.form-group
input.form-control(type='text', v-model='heroID', :placeholder="'User ID or Username'")
.form-group
button.btn.btn-secondary(@click='loadHero(heroID)')
| Load User
.row
.form.col-6(v-if='hero && hero.profile', submit='saveHero(hero)')
router-link(:to="{'name': 'userProfile', 'params': {'userId': hero._id}}")
h3 @{{hero.auth.local.username}} &nbsp; / &nbsp; {{hero.profile.name}}
.form-group
label Contributor Title
input.form-control(type='text', v-model='hero.contributor.text')
small Common titles: <strong>Ambassador, Artisan, Bard, Blacksmith, Challenger, Comrade, Fletcher, Linguist, Linguistic Scribe, Scribe, Socialite, Storyteller</strong>. Rare titles: Advisor, Chamberlain, Designer, Mathematician, Shirtster, Spokesperson, Statistician, Tinker, Transcriber, Troubadour.
.form-group
label Contributor Tier
input.form-control(type='number', v-model='hero.contributor.level')
small 1-7 for normal contributors, 8 for moderators, 9 for staff. This determines which items, pets, and mounts are available, and name-tag coloring. Tiers 8 and 9 are automatically given admin status.
|&nbsp;
a(target='_blank', href='https://trello.com/c/wkFzONhE/277-contributor-gear') More details (1-7)
|,&nbsp;
a(target='_blank', href='https://github.com/HabitRPG/habitica/issues/3801') more details (8-9)
.form-group
label Contributions
textarea.form-control(cols=5, v-model='hero.contributor.contributions')
.form-group
label Balance
input.form-control(type='number', step="any", v-model='hero.balance')
small
span '{{ hero.balance }}' is in USD, <em>not</em> in Gems. E.g., if this number is 1, it means 4 Gems. Only use this option when manually granting Gems to players, don't use it when granting contributor tiers. Contrib tiers will automatically add Gems.
.accordion
.accordion-group(heading='Items')
h4.expand-toggle(:class="{'open': expandItems}", @click="expandItems = !expandItems") Update Item
.form-group.well(v-if="expandItems")
input.form-control(type='text',placeholder='Path (eg, items.pets.BearCub-Base)',v-model='hero.itemPath')
small.muted Enter the <strong>item path</strong>. E.g., <code>items.pets.BearCub-Zombie</code> or <code>items.gear.owned.head_special_0</code> or <code>items.gear.equipped.head</code>. You can find all the item paths below.
br
input.form-control(type='text',placeholder='Value (eg, 5)',v-model='hero.itemVal')
small.muted Enter the <strong>item value</strong>. E.g., <code>5</code> or <code>false</code> or <code>head_warrior_3</code>. All values are listed in the All Item Paths section below.
.accordion
.accordion-group(heading='All Item Paths')
pre {{allItemPaths}}
.accordion-group(heading='Current Items')
pre {{hero.items}}
.accordion-group(heading='Auth')
h4.expand-toggle(:class="{'open': expandAuth}", @click="expandAuth = !expandAuth") Auth
div(v-if="expandAuth")
pre {{hero.auth}}
.form-group
.checkbox
label
input(type='checkbox', v-if='hero.flags', v-model='hero.flags.chatShadowMuted')
strong Chat Shadow Muting On
.form-group
.checkbox
label
input(type='checkbox', v-if='hero.flags', v-model='hero.flags.chatRevoked')
strong Chat Privileges Revoked
.form-group
.checkbox
label
input(type='checkbox', v-model='hero.auth.blocked')
| Blocked
// h4 Backer Status
// Add backer stuff like tier, disable adds, etcs
.form-group
button.form-control.btn.btn-primary(@click='saveHero()')
| Save
.table-responsive
table.table.table-striped
thead
tr
th {{ $t('name') }}
th(v-if='user.contributor && user.contributor.admin') {{ $t('UUID') }}
th {{ $t('contribLevel') }}
th {{ $t('title') }}
th {{ $t('contributions') }}
tbody
tr(v-for='(hero, index) in heroes')
td
user-link(v-if='hero.contributor && hero.contributor.admin', :user='hero', :popover="$t('gamemaster')", popover-trigger='mouseenter', popover-placement='right')
user-link(v-if='!hero.contributor || !hero.contributor.admin', :user='hero')
td(v-if='user.contributor.admin', @click='populateContributorInput(hero._id, index)').btn-link {{hero._id}}
td {{hero.contributor.level}}
td {{hero.contributor.text}}
td
div(v-markdown='hero.contributor.contributions', target='_blank')
</template>
<style lang="scss" scoped>
h4.expand-toggle::after {
margin-left: 5px;
}
</style>
<script>
import each from 'lodash/each';
import markdownDirective from 'client/directives/markdown';
import styleHelper from 'client/mixins/styleHelper';
import { mapState } from 'client/libs/store';
import quests from 'common/script/content/quests';
import { mountInfo, petInfo } from 'common/script/content/stable';
import { food, hatchingPotions, special } from 'common/script/content';
import gear from 'common/script/content/gear';
import notifications from 'client/mixins/notifications';
import userLink from '../userLink';
export default {
mixins: [notifications, styleHelper],
components: {
userLink,
},
data () {
return {
heroes: [],
hero: {},
heroID: '',
currentHeroIndex: -1,
allItemPaths: this.getAllItemPaths(),
quests,
mountInfo,
petInfo,
food,
hatchingPotions,
special,
gear,
expandItems: false,
expandAuth: false,
};
},
directives: {
markdown: markdownDirective,
},
async mounted () {
this.heroes = await this.$store.dispatch('hall:getHeroes');
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
getAllItemPaths () {
// let questsFormat = this.getFormattedItemReference('items.quests', keys(this.quests), 'Numeric Quantity');
// let mountsFormat = this.getFormattedItemReference('items.mounts', keys(this.mountInfo), 'Boolean');
// let foodFormat = this.getFormattedItemReference('items.food', keys(this.food), 'Numeric Quantity');
// let eggsFormat = this.getFormattedItemReference('items.eggs', keys(this.eggs), 'Numeric Quantity');
// let hatchingPotionsFormat = this.getFormattedItemReference('items.hatchingPotions', keys(this.hatchingPotions), 'Numeric Quantity');
// let petsFormat = this.getFormattedItemReference('items.pets', keys(this.petInfo), '-1: Owns Mount, 0: Not Owned, 1-49: Progress to mount');
// let specialFormat = this.getFormattedItemReference('items.special', keys(this.special), 'Numeric Quantity');
// let gearFormat = this.getFormattedItemReference('items.gear.owned', keys(this.gear.flat), 'Boolean');
//
// let equippedGearFormat = ''; // @TODO: '\nEquipped Gear:\n\titems.gear.{equipped/costume}.{head/headAccessory/eyewear/armor/body/back/shield/weapon}.{gearKey}\n';
// let equippedPetFormat = ''; // @TODO: '\nEquipped Pet:\n\titems.currentPet.{petKey}\n';
// let equippedMountFormat = ''; // @TODO: '\nEquipped Mount:\n\titems.currentMount.{mountKey}\n';
//
// let data = questsFormat.concat(mountsFormat, foodFormat, eggsFormat, hatchingPotionsFormat, petsFormat, specialFormat, gearFormat, equippedGearFormat, equippedPetFormat, equippedMountFormat);
//
// return data;
},
getFormattedItemReference (pathPrefix, itemKeys, values) {
let finishedString = '\n'.concat('path: ', pathPrefix, ', ', 'value: {', values, '}\n');
each(itemKeys, (key) => {
finishedString = finishedString.concat('\t', pathPrefix, '.', key, '\n');
});
return finishedString;
},
async loadHero (uuid, heroIndex) {
this.currentHeroIndex = heroIndex;
let hero = await this.$store.dispatch('hall:getHero', { uuid });
this.hero = Object.assign({}, hero);
if (!this.hero.flags) {
this.hero.flags = {
chatRevoked: false,
chatShadowMuted: false,
};
}
this.expandItems = false;
this.expandAuth = false;
},
async saveHero () {
this.hero.contributor.admin = this.hero.contributor.level > 7 ? true : false;
let heroUpdated = await this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
this.text('User updated');
this.hero = {};
this.heroID = -1;
this.heroes[this.currentHeroIndex] = heroUpdated;
this.currentHeroIndex = -1;
},
populateContributorInput (id, index) {
this.heroID = id;
window.scrollTo(0, 200);
this.loadHero(id, index);
},
},
};
</script>
@@ -0,0 +1,19 @@
<template lang="pug">
.row
secondary-menu.col-12
router-link.nav-link(:to="{name: 'contributors'}", exact, :class="{'active': $route.name === 'contributors'}") {{ $t('hallContributors') }}
router-link.nav-link(:to="{name: 'patrons'}", :class="{'active': $route.name === 'patrons'}") {{ $t('hallPatrons') }}
.col-12
router-view
</template>
<script>
import SecondaryMenu from 'client/components/secondaryMenu';
export default {
components: {
SecondaryMenu,
},
};
</script>
@@ -0,0 +1,41 @@
<template lang="pug">
.row
small.muted {{ $t('blurbHallPatrons') }}
.table-responsive
table.table.table-striped(infinite-scroll="loadMore()")
thead
tr
th {{ $t('name') }}
th(v-if='user.contributor.admin') {{ $t('UUID') }}
th {{ $t('backerTier') }}
tbody
tr(v-for='patron in patrons')
td
a.label.label-default(v-class='userLevelStyle(patron)', @click='clickMember(patron._id, true)')
| {{patron.profile.name}}
td(v-if='user.contributor.admin') {{patron._id}}
td {{patron.backer.tier}}
</template>
<script>
import { mapState } from 'client/libs/store';
import styleHelper from 'client/mixins/styleHelper';
export default {
mixins: [styleHelper],
data () {
return {
patrons: [],
};
},
async mounted () {
this.patrons = await this.$store.dispatch('hall:getPatrons', { page: 0 });
},
computed: {
...mapState({user: 'user.data'}),
},
methods: {
// @TODO: Import member modal - clickMember()
},
};
</script>
@@ -0,0 +1,191 @@
<template lang="pug">
div
invite-modal(:group='inviteModalGroup', :groupType='inviteModalGroupType')
create-party-modal
#app-header.row(:class="{'hide-header': $route.name === 'groupPlan'}")
members-modal(:hide-badge="true")
member-details(
:member="user",
:class-badge-position="'next-to-name'",
:is-header="true",
)
.view-party.d-flex.align-items-center(
v-if="hasParty",
)
button.btn.btn-primary.view-party-button(@click='showPartyMembers()') {{ $t('viewParty') }}
.party-members.d-flex(
v-if="hasParty",
v-resize="1500",
@resized="setPartyMembersWidth($event)"
)
member-details(
v-for="(member, $index) in sortedPartyMembers",
:key="member._id",
v-if="member._id !== user._id && $index < membersToShow",
:member="member",
condensed=true,
@onHover="expandMember(member._id)",
:expanded="member._id === expandedMember",
:is-header="true",
:class-badge-position="'hidden'",
)
.no-party.d-flex.justify-content-center.text-center(v-else)
.align-self-center
h3 {{ $t('battleWithFriends') }}
span.small-text(v-html="$t('inviteFriendsParty')")
br
button.btn.btn-primary(@click='createOrInviteParty()') {{ user.party._id ? $t('inviteFriends') : $t('startAParty') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
#app-header {
padding-left: 24px;
padding-top: 9px;
padding-bottom: 8px;
background: $purple-50;
color: $header-color;
flex-wrap: nowrap;
position: relative;
max-height: 10.25rem;
}
.hide-header {
display: none;
}
.no-party, .party-members {
flex-grow: 1;
}
.view-party {
position: absolute;
z-index: 10;
right: 0;
padding-right: 40px;
padding-left: 10px;
height: calc(100% - 9px);
background-image: linear-gradient(to right, rgba($purple-50, 0), $purple-50);
}
.no-party {
.small-text {
color: $header-color;
flex-wrap: nowrap;
}
h3 {
color: $white;
margin-bottom: 4px;
}
.btn {
margin-top: 16px;
}
}
@media only screen and (max-width: 768px) {
.view-party-button {
display: none;
}
}
</style>
<script>
import orderBy from 'lodash/orderBy';
import { mapGetters, mapActions } from 'client/libs/store';
import MemberDetails from '../memberDetails';
import createPartyModal from '../groups/createPartyModal';
import inviteModal from '../groups/inviteModal';
import membersModal from '../groups/membersModal';
import ResizeDirective from 'client/directives/resize.directive';
export default {
directives: {
resize: ResizeDirective,
},
components: {
MemberDetails,
createPartyModal,
inviteModal,
membersModal,
},
data () {
return {
expandedMember: null,
currentWidth: 0,
inviteModalGroup: undefined,
inviteModalGroupType: undefined,
};
},
computed: {
...mapGetters({
user: 'user:data',
partyMembers: 'party:members',
}),
showHeader () {
if (this.$store.state.hideHeader) return false;
return true;
},
hasParty () {
return this.user.party && this.user.party._id && this.partyMembers && this.partyMembers.length > 1;
},
membersToShow () {
return Math.floor(this.currentWidth / 140) + 1;
},
sortedPartyMembers () {
return orderBy(this.partyMembers, [this.user.party.order], [this.user.party.orderAscending]);
},
},
methods: {
...mapActions({
getPartyMembers: 'party:getMembers',
}),
expandMember (memberId) {
if (this.expandedMember === memberId) {
this.expandedMember = null;
} else {
this.expandedMember = memberId;
}
},
createOrInviteParty () {
if (this.user.party._id) {
this.$root.$emit('inviteModal::inviteToGroup', this.user.party);
} else {
this.$root.$emit('bv::show::modal', 'create-party-modal');
}
},
async showPartyMembers () {
const party = await this.$store.dispatch('party:getParty');
this.$root.$emit('habitica:show-member-modal', {
groupId: party.data._id,
viewingMembers: this.partyMembers,
group: party.data,
});
},
setPartyMembersWidth ($event) {
if (this.currentWidth !== $event.width) {
this.currentWidth = $event.width;
}
},
},
created () {
if (this.user.party && this.user.party._id) {
this.$store.state.memberModalOptions.groupId = this.user.party._id;
this.getPartyMembers();
}
},
mounted () {
this.$root.$on('inviteModal::inviteToGroup', (group) => {
this.inviteModalGroup = group;
this.inviteModalGroupType = group.type === 'guild' ? 'Guild' : 'Party';
this.$root.$emit('bv::show::modal', 'invite-modal');
});
},
destroyed () {
this.$root.off('inviteModal::inviteToGroup');
},
};
</script>
@@ -0,0 +1,531 @@
<template lang="pug">
div
inbox-modal
creator-intro
profileModal
report-flag-modal
send-gems-modal
b-navbar.topbar.navbar-inverse.static-top(toggleable="lg", type="dark", :class="navbarZIndexClass")
b-navbar-brand.brand(aria-label="Habitica")
.logo.svg-icon.d-none.d-xl-block(v-html="icons.logo")
.svg-icon.gryphon.d-xs-block.d-xl-none
b-navbar-toggle(target='menu_collapse').menu-toggle
.quick-menu.mobile-only.form-inline
a.item-with-icon(@click="sync", v-b-tooltip.hover.bottom="$t('sync')", :aria-label="$t('sync')")
.top-menu-icon.svg-icon(v-html="icons.sync")
notification-menu.item-with-icon
user-dropdown.item-with-icon
b-collapse#menu_collapse(v-model="menuIsOpen").collapse.navbar-collapse
b-navbar-nav.menu-list
b-nav-item.topbar-item(:class="{'active': $route.path === '/'}" tag="li", :to="{name: 'tasks'}", exact) {{ $t('tasks') }}
li.topbar-item(:class="{'active': $route.path.startsWith('/inventory'), 'down': $route.path.startsWith('/inventory') && this.isDesktop()}").droppable
.chevron.rotate(@click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'items'}") {{ $t('inventory') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'items'}", exact) {{ $t('items') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'equipment'}") {{ $t('equipment') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'stable'}") {{ $t('stable') }}
li.topbar-item(:class="{'active': $route.path.startsWith('/shop'), 'down': $route.path.startsWith('/shop') && this.isDesktop()}").droppable
.chevron.rotate(@click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'market'}") {{ $t('shops') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'market'}", exact) {{ $t('market') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'quests'}") {{ $t('quests') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'seasonal'}") {{ $t('titleSeasonalShop') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'time'}") {{ $t('titleTimeTravelers') }}
b-nav-item.topbar-item(:class="{'active': $route.path.startsWith('/party')}" tag="li", :to="{name: 'party'}", v-if='this.user.party._id') {{ $t('party') }}
b-nav-item.topbar-item(:class="{'active': $route.path.startsWith('/party')}" @click='openPartyModal()', v-if='!this.user.party._id') {{ $t('party') }}
li.topbar-item(:class="{'active': $route.path.startsWith('/groups'), 'down': $route.path.startsWith('/groups') && this.isDesktop()}").droppable
.chevron.rotate(@click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'tavern'}") {{ $t('guilds') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'tavern'}") {{ $t('tavern') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'myGuilds'}") {{ $t('myGuilds') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'guildsDiscovery'}") {{ $t('guildsDiscovery') }}
li.topbar-item(:class="{'active': $route.path.startsWith('/group-plans'), 'down': $route.path.startsWith('/group-plans') && this.isDesktop()}").droppable
.chevron.rotate(v-if="groupPlans.length > 0", @click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'groupPlan'}") {{ $t('group') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(v-for='group in groupPlans', :key='group._id', :to="{name: 'groupPlanDetailTaskInformation', params: {groupId: group._id}}") {{ group.name }}
li.topbar-item(:class="{'active': $route.path.startsWith('/challenges'), 'down': $route.path.startsWith('/challenges') && this.isDesktop()}").droppable
.chevron.rotate(@click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'myChallenges'}") {{ $t('challenges') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'myChallenges'}") {{ $t('myChallenges') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'findChallenges'}") {{ $t('findChallenges') }}
li.topbar-item(:class="{'active': $route.path.startsWith('/help'), 'down': $route.path.startsWith('/help') && this.isDesktop()}").droppable
.chevron.rotate(@click='dropdownMobile($event)')
.chevron-icon-down(v-html="icons.chevronDown", v-once)
router-link.nav-link(:to="{name: 'faq'}") {{ $t('help') }}
.topbar-dropdown
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'faq'}") {{ $t('faq') }}
router-link.topbar-dropdown-item.dropdown-item(:to="{name: 'overview'}") {{ $t('overview') }}
router-link.topbar-dropdown-item.dropdown-item(to="/groups/guild/a29da26b-37de-4a71-b0c6-48e72a900dac") {{ $t('reportBug') }}
router-link.topbar-dropdown-item.dropdown-item(to="/groups/guild/5481ccf3-5d2d-48a9-a871-70a7380cee5a") {{ $t('askAQuestion') }}
a.topbar-dropdown-item.dropdown-item(href="https://trello.com/c/odmhIqyW/440-read-first-table-of-contents", target='_blank') {{ $t('requestAF') }}
a.topbar-dropdown-item.dropdown-item(href="http://habitica.fandom.com/wiki/Contributing_to_Habitica", target='_blank') {{ $t('contributing') }}
a.topbar-dropdown-item.dropdown-item(href="http://habitica.fandom.com/wiki/Habitica_Wiki", target='_blank') {{ $t('wiki') }}
a.topbar-dropdown-item.dropdown-item(@click='modForm()') {{ $t('contactForm') }}
.currency-tray.form-inline
.item-with-icon(v-if="userHourglasses > 0")
.top-menu-icon.svg-icon(v-html="icons.hourglasses", v-b-tooltip.hover.bottom="$t('mysticHourglassesTooltip')")
span {{ userHourglasses }}
.item-with-icon
a.top-menu-icon.svg-icon.gem(:aria-label="$t('gems')", href="#buy-gems" v-html="icons.gem", @click.prevent='showBuyGemsModal("gems")', v-b-tooltip.hover.bottom="$t('gems')")
span {{userGems}}
.item-with-icon.gold
.top-menu-icon.svg-icon(:aria-label="$t('gold')", v-html="icons.gold", v-b-tooltip.hover.bottom="$t('gold')")
span {{Math.floor(user.stats.gp * 100) / 100}}
.form-inline.desktop-only
a.item-with-icon(@click="sync", @keyup.enter="sync", role="link", :aria-label="$t('sync')", tabindex="0", v-b-tooltip.hover.bottom="$t('sync')")
.top-menu-icon.svg-icon(v-html="icons.sync")
notification-menu.item-with-icon
user-dropdown.item-with-icon
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/utils.scss';
@media only screen and (max-width: 1200px) {
.chevron {
display: none
}
.gryphon {
background-image: url('~assets/images/melior@3x.png');
width: 30px;
height: 30px;
background-size: cover;
color: $white;
margin: 0 auto;
}
.topbar-item {
font-size: 14px !important;
}
}
@media only screen and (min-width: 992px) {
.chevron {
display: none
}
.mobile-only {
display: none !important;
}
.topbar {
max-height: 56px;
.currency-tray {
margin-left: auto;
}
.topbar-item {
padding-top: 5px;
height: 56px;
&:hover {
background: $purple-200;
}
&.active:not(:hover) {
box-shadow: 0px -4px 0px $purple-300 inset;
}
}
.topbar-dropdown {
position: absolute;
}
}
}
@media only screen and (max-width: 992px) {
.brand {
margin: 0;
}
.gryphon {
position: absolute;
left: calc(50% - 30px);
top: 10px;
}
#menu_collapse {
margin: 0.6em -16px -8px;
overflow: auto;
flex-direction: column;
background-color: $purple-100;
.menu-list {
width: 100%;
order: 1;
text-align: center;
.topbar-dropdown {
transition: max-height 0.25s ease;
}
.topbar-dropdown-item {
background: #432874;
border-bottom: #6133b4 solid 1px;
}
.chevron {
width: 20%;
height: 42px;
position: absolute;
right: 0;
top: 0;
display: block;
}
.chevron-icon-down {
width: 14px;
top: 11px;
right: 12px;
position: absolute;
display: block;
transition: transform 0.25s ease;
}
.down .rotate .chevron-icon-down {
transform: rotate(-180deg);
}
.topbar-item {
position: relative;
&.active {
background: #6133b4;
}
background: #4f2a93;
border-bottom: #6133b4 solid 1px;
}
}
}
.currency-tray {
justify-content: center;
min-height: 40px;
background: #271b3d;
width: 100%;
}
.desktop-only {
display: none !important;
}
}
.menu-toggle {
border: none;
}
#menu_collapse {
display: flex;
justify-content: space-between;
}
.topbar {
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);
a {
color: white !important;
}
}
.navbar-z-index {
&-normal {
z-index: 1080;
}
&-modal {
z-index: 1035;
}
}
.logo {
padding-left: 10px;
width: 128px;
height: 28px;
}
.quick-menu {
display: flex;
margin-left: auto;
}
.currency-tray {
display: flex;
}
.topbar-item {
font-size: 16px;
color: $white !important;
font-weight: bold;
transition: none;
.topbar-dropdown {
overflow: hidden;
max-height: 0;
.topbar-dropdown-item {
line-height: 1.5;
font-size: 16px;
}
}
>a {
padding: .8em 1em !important;
}
&.down {
color: $white !important;
background: $purple-200;
.topbar-dropdown {
margin-top: 0; // Remove gap between navbar and drop-down.
background: $purple-200;
border-radius: 0px;
border: none;
box-shadow: none;
padding: 0px;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
.topbar-dropdown-item {
font-size: 16px;
box-shadow: none;
color: $white;
border: none;
line-height: 1.5;
display: list-item;
&.active {
background: $purple-300;
}
&:hover {
background: $purple-300;
&:last-child {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
}
}
}
}
}
.dropdown + .dropdown {
margin-left: 0px;
}
.item-with-icon {
color: $white;
font-size: 16px;
font-weight: normal;
white-space: nowrap;
span {
font-weight: bold;
}
&.gold {
margin-right: 24px;
}
&:focus /deep/ .top-menu-icon.svg-icon,
&:hover /deep/ .top-menu-icon.svg-icon {
color: $white;
}
& /deep/ .top-menu-icon.svg-icon {
color: $header-color;
vertical-align: bottom;
display: inline-block;
width: 24px;
height: 24px;
margin-right: 12px;
margin-left: 12px;
}
}
.menu-icon {
margin-left: 24px;
}
.gem:hover {
cursor: pointer;
}
.message-count {
background-color: $blue-50;
border-radius: 50%;
height: 20px;
width: 20px;
float: right;
color: $white;
text-align: center;
font-weight: bold;
font-size: 12px;
}
.message-count.top-count {
background-color: $red-50;
position: absolute;
right: 0;
top: -0.5em;
padding: .2em;
}
</style>
<script>
import { mapState, mapGetters } from 'client/libs/store';
import * as Analytics from 'client/libs/analytics';
import { goToModForm } from 'client/libs/modform';
import gemIcon from 'assets/svg/gem.svg';
import goldIcon from 'assets/svg/gold.svg';
import syncIcon from 'assets/svg/sync.svg';
import svgHourglasses from 'assets/svg/hourglass.svg';
import chevronDownIcon from 'assets/svg/chevron-down.svg';
import logo from 'assets/svg/logo.svg';
import creatorIntro from '../creatorIntro';
import InboxModal from '../userMenu/inbox.vue';
import notificationMenu from './notificationsDropdown';
import profileModal from '../userMenu/profileModal';
import reportFlagModal from '../chat/reportFlagModal';
import sendGemsModal from 'client/components/payments/sendGemsModal';
import sync from 'client/mixins/sync';
import userDropdown from './userDropdown';
export default {
components: {
creatorIntro,
InboxModal,
notificationMenu,
profileModal,
reportFlagModal,
sendGemsModal,
userDropdown,
},
mixins: [sync],
data () {
return {
isUserDropdownOpen: false,
menuIsOpen: false,
icons: Object.freeze({
gem: gemIcon,
gold: goldIcon,
hourglasses: svgHourglasses,
sync: syncIcon,
logo,
chevronDown: chevronDownIcon,
}),
};
},
computed: {
...mapGetters({
userGems: 'user:gems',
}),
...mapState({
user: 'user.data',
userHourglasses: 'user.data.purchased.plan.consecutive.trinkets',
groupPlans: 'groupPlans',
modalStack: 'modalStack',
}),
navbarZIndexClass () {
if (this.modalStack.length > 0) {
return 'navbar-z-index-modal';
}
return 'navbar-z-index-normal';
},
},
mounted () {
this.getUserGroupPlans();
Array.from(document.getElementById('menu_collapse').getElementsByTagName('a')).forEach(link => {
link.addEventListener('click', this.closeMenu);
});
Array.from(document.getElementsByClassName('topbar-item')).forEach(link => {
link.addEventListener('mouseenter', this.dropdownDesktop);
link.addEventListener('mouseleave', this.dropdownDesktop);
});
},
methods: {
modForm () {
goToModForm(this.user);
},
toggleUserDropdown () {
this.isUserDropdownOpen = !this.isUserDropdownOpen;
},
async getUserGroupPlans () {
this.$store.state.groupPlans = await this.$store.dispatch('guilds:getGroupPlans');
},
openPartyModal () {
this.$root.$emit('bv::show::modal', 'create-party-modal');
},
showBuyGemsModal (startingPage) {
this.$store.state.gemModalOptions.startingPage = startingPage;
Analytics.track({
hitType: 'event',
eventCategory: 'button',
eventAction: 'click',
eventLabel: 'Gems > Toolbar',
});
this.$root.$emit('bv::show::modal', 'buy-gems', {alreadyTracked: true});
},
dropdownDesktop (hover) {
if (this.isDesktop() && hover.target.classList.contains('droppable')) {
this.dropdown(hover.target);
}
},
dropdownMobile (click) {
this.dropdown(click.currentTarget.parentElement);
},
dropdown (element) {
let droppedElement = document.getElementsByClassName('down')[0];
if (droppedElement && droppedElement !== element) {
droppedElement.classList.remove('down');
if (droppedElement.lastChild) {
droppedElement.lastChild.style.maxHeight = 0;
}
}
element.classList.toggle('down');
element.lastChild.style.maxHeight = element.classList.contains('down') ? `${element.lastChild.scrollHeight}px` : 0;
},
closeMenu () {
if (this.isMobile()) {
this.menuIsOpen = false;
Array.from(document.getElementsByClassName('droppable')).forEach(droppableElement => {
droppableElement.classList.remove('down');
droppableElement.lastChild.style.maxHeight = 0;
});
}
},
isMobile () {
return document.documentElement.clientWidth < 992;
},
isDesktop () {
return !this.isMobile();
},
},
};
</script>
@@ -0,0 +1,33 @@
<template lang="pug" functional>
span.message-count(
:class="{'top-count': props.top === true, 'top-count-gray': props.gray === true}"
) {{props.count}}
</template>
<style lang="scss">
@import '~client/assets/scss/colors.scss';
.message-count {
background-color: $blue-50;
border-radius: 50%;
height: 20px;
width: 20px;
float: right;
color: $white;
text-align: center;
font-weight: bold;
font-size: 12px;
}
.message-count.top-count {
position: absolute;
right: 0.3em;
top: -0.8em;
padding: 0.2em;
background-color: $red-50;
}
.message-count.top-count-gray {
background-color: $gray-200;
}
</style>
@@ -0,0 +1,162 @@
<template lang="pug">
.notification.dropdown-item.dropdown-separated.d-flex.justify-content-between(
@click="clicked"
)
.notification-icon.d-flex.justify-content-center.align-items-center(
v-if="hasIcon",
:class="{'is-not-bailey': isNotBailey}",
)
slot(name="icon")
.notification-content
slot(name="content")
.notification-remove(@click.stop="canRemove ? remove() : null",)
.svg-icon(
v-if="canRemove",
v-html="icons.close",
)
</template>
<style lang="scss"> // Not scoped because the classes could be used in i18n strings
@import '~client/assets/scss/colors.scss';
.notification-small {
font-size: 12px;
line-height: 1.33;
color: $gray-200;
}
.notification-ellipses {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.notification-bold {
font-weight: bold;
}
.notification-bold-blue {
font-weight: bold;
color: $blue-10;
}
.notification-bold-purple {
font-weight: bold;
color: $purple-300;
}
.notification-yellow {
color: #bf7d1a;
}
.notification-green {
color: #1CA372;
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.notification {
width: 378px;
max-width: 100%;
padding: 9px 20px 10px 24px;
overflow: hidden;
&:active, &:hover {
color: inherit;
}
}
.notification-icon {
margin-right: 16px;
&.is-not-bailey {
width: 31px;
height: 32px;
}
}
.notifications-buttons {
margin-top: 12px;
.btn {
margin-right: 8px;
}
}
.notification-content {
// total distance from notification top and bottom edges are 15 and 16 pixels
margin-top: 6px;
margin-bottom: 6px;
flex-grow: 1;
white-space: normal;
font-size: 14px;
line-height: 1.43;
color: $gray-50;
max-width: calc(100% - 26px); // to make space for the close icon
}
.notification-remove {
// total distance from the notification top edge is 20 pixels
margin-top: 7px;
width: 18px;
height: 18px;
margin-left: 12px;
padding: 4px;
.svg-icon {
width: 10px;
height: 10px;
}
}
</style>
<script>
import closeIcon from 'assets/svg/close.svg';
import { mapActions, mapState } from 'client/libs/store';
export default {
props: ['notification', 'canRemove', 'hasIcon', 'readAfterClick'],
data () {
return {
icons: Object.freeze({
close: closeIcon,
}),
};
},
computed: {
...mapState({user: 'user.data'}),
isNotBailey () {
return this.notification.type !== 'NEW_STUFF';
},
},
methods: {
...mapActions({
readNotification: 'notifications:readNotification',
}),
clicked () {
if (this.readAfterClick === true) {
this.readNotification({notificationId: this.notification.id});
}
this.$emit('click');
},
remove () {
if (this.notification.type === 'NEW_CHAT_MESSAGE') {
const groupId = this.notification.data.group.id;
this.$store.dispatch('chat:markChatSeen', {groupId});
if (this.user.newMessages[groupId]) {
this.$delete(this.user.newMessages, groupId);
}
} else {
this.readNotification({notificationId: this.notification.id});
}
},
},
};
</script>
@@ -0,0 +1,35 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="true",
:notification="notification",
:read-after-click="true",
@click="action"
)
div(slot="content", v-html="$t('cardReceived', {card: cardString})")
div(slot="icon", :class="cardClass")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
cardString () {
return this.$t(`${this.notification.data.card}Card`);
},
cardClass () {
return `notif_inventory_special_${this.notification.data.card}`;
},
},
methods: {
action () {
this.$router.push({name: 'items'});
},
},
};
</script>
@@ -0,0 +1,3 @@
<template lang="pug" functional>
div {{ props.notification }}
</template>
@@ -0,0 +1,76 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
@click="action",
)
div(slot="content")
div(v-html="notification.data.message")
.notifications-buttons
.btn.btn-small.btn-success(@click.stop="approve()") {{ $t('approve') }}
.btn.btn-small.btn-warning(@click.stop="needsWork()") {{ $t('needsWork') }}
</template>
<script>
import BaseNotification from './base';
import { mapState } from 'client/libs/store';
import sync from 'client/mixins/sync';
export default {
mixins: [sync],
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
computed: {
...mapState({user: 'user.data'}),
// Check that the notification has all the necessary data (old ones are missing some fields)
notificationHasData () {
return Boolean(this.notification.data.groupTaskId && this.notification.data.userId);
},
},
methods: {
action () {
const groupId = this.notification.data.group.id;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
async approve () {
// Redirect users to the group tasks page if the notification doesn't have data
if (!this.notificationHasData) {
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: {
groupId: this.notification.data.groupId,
}});
return;
}
await this.$store.dispatch('tasks:approve', {
taskId: this.notification.data.groupTaskId,
userId: this.notification.data.userId,
});
this.sync();
},
async needsWork () {
// Redirect users to the group tasks page if the notification doesn't have data
if (!this.notificationHasData) {
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: {
groupId: this.notification.data.groupId,
}});
return;
}
if (!confirm(this.$t('confirmNeedsWork'))) return;
await this.$store.dispatch('tasks:needsWork', {
taskId: this.notification.data.groupTaskId,
userId: this.notification.data.userId,
});
this.sync();
},
},
};
</script>
@@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
.notification-green(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.groupId;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>
@@ -0,0 +1,26 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
div(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
this.$router.push({ name: 'tasks'});
},
},
};
</script>
@@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
div(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.groupId;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>
@@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
.notification-yellow(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.group.id;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>

Some files were not shown because too many files have changed in this diff Show More