diff --git a/test/api/v3/integration/groups/GET-group-plans.test.js b/test/api/v3/integration/groups/GET-group-plans.test.js
new file mode 100644
index 0000000000..353a591d4e
--- /dev/null
+++ b/test/api/v3/integration/groups/GET-group-plans.test.js
@@ -0,0 +1,32 @@
+import {
+ generateUser,
+ generateGroup,
+} from '../../../../helpers/api-v3-integration.helper';
+
+describe('GET /group-plans', () => {
+ let user;
+ let groupPlan;
+
+ before(async () => {
+ user = await generateUser({balance: 4});
+ groupPlan = await generateGroup(user,
+ {
+ name: 'public guild - is member',
+ type: 'guild',
+ privacy: 'public',
+ },
+ {
+ purchased: {
+ plan: {
+ customerId: 'existings',
+ },
+ },
+ });
+ });
+
+ it('returns group plans for the user', async () => {
+ let groupPlans = await user.get('/group-plans');
+
+ expect(groupPlans[0]._id).to.eql(groupPlan._id);
+ });
+});
diff --git a/webpack/config/prod.env.js b/webpack/config/prod.env.js
index 4da1052a0c..b5dc59b6b0 100644
--- a/webpack/config/prod.env.js
+++ b/webpack/config/prod.env.js
@@ -1,3 +1,46 @@
-module.exports = {
+const nconf = require('nconf');
+const { join, resolve } = require('path');
+
+const PATH_TO_CONFIG = join(resolve(__dirname, '../../config.json'));
+let configFile = PATH_TO_CONFIG;
+
+nconf
+ .argv()
+ .env()
+ .file('user', configFile);
+
+nconf.set('IS_PROD', nconf.get('NODE_ENV') === 'production');
+nconf.set('IS_DEV', nconf.get('NODE_ENV') === 'development');
+nconf.set('IS_TEST', nconf.get('NODE_ENV') === 'test');
+
+// @TODO: Check if we can import from client. Items like admin emails can be imported
+// and that should be prefered
+
+// To avoid stringifying more data then we need,
+// items from `env` used on the client will have to be specified in this array
+// @TODO: Do we need? const CLIENT_VARS = ['language', 'isStaticPage', 'availableLanguages', 'translations',
+// 'FACEBOOK_KEY', 'GOOGLE_CLIENT_ID', 'NODE_ENV', 'BASE_URL', 'GA_ID',
+// 'AMAZON_PAYMENTS', 'STRIPE_PUB_KEY', 'AMPLITUDE_KEY',
+// 'worldDmg', 'mods', 'IS_MOBILE', 'PUSHER:KEY', 'PUSHER:ENABLED'];
+
+let env = {
NODE_ENV: '"production"',
+ // clientVars: CLIENT_VARS,
+ AMAZON_PAYMENTS: {
+ SELLER_ID: `"${nconf.get('AMAZON_PAYMENTS:SELLER_ID')}"`,
+ CLIENT_ID: `"${nconf.get('AMAZON_PAYMENTS:CLIENT_ID')}"`,
+ },
+ EMAILS: {
+ COMMUNITY_MANAGER_EMAIL: `"${nconf.get('EMAILS:COMMUNITY_MANAGER_EMAIL')}"`,
+ TECH_ASSISTANCE_EMAIL: `"${nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL')}"`,
+ PRESS_ENQUIRY_EMAIL: `"${nconf.get('EMAILS:PRESS_ENQUIRY_EMAIL')}"`,
+ },
};
+
+'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY GOOGLE_CLIENT_ID AMPLITUDE_KEY PUSHER:KEY PUSHER:ENABLED'
+ .split(' ')
+ .forEach(key => {
+ env[key] = `"${nconf.get(key)}"`;
+ });
+
+module.exports = env;
diff --git a/website/client/components/appMenu.vue b/website/client/components/appMenu.vue
index 0bb0b53aa5..47e07a4647 100644
--- a/website/client/components/appMenu.vue
+++ b/website/client/components/appMenu.vue
@@ -30,8 +30,16 @@ div
router-link.dropdown-item(:to="{name: 'tavern'}") {{ $t('tavern') }}
router-link.dropdown-item(:to="{name: 'myGuilds'}") {{ $t('myGuilds') }}
router-link.dropdown-item(:to="{name: 'guildsDiscovery'}") {{ $t('guildsDiscovery') }}
- router-link.nav-item.dropdown(tag="li", :to="{name: 'groupPlan'}", :class="{'active': $route.path.startsWith('/group-plan')}")
+ router-link.nav-item.dropdown(
+ v-if='groupPlans.length === 0',
+ tag="li",
+ :to="{name: 'groupPlan'}",
+ :class="{'active': $route.path.startsWith('/group-plan')}")
+ a.nav-link(v-once) {{ $t('group') }}
+ .nav-item.dropdown(v-if='groupPlans.length > 0', :class="{'active': $route.path.startsWith('/group-plans')}")
a.nav-link(v-once) {{ $t('group') }}
+ .dropdown-menu
+ router-link.dropdown-item(v-for='group in groupPlans', :key='group._id', :to="{name: 'groupPlanDetailTaskInformation', params: {groupId: group._id}}") {{ group.name }}
router-link.nav-item(tag="li", :to="{name: 'myChallenges'}", exact)
a.nav-link(v-once) {{ $t('challenges') }}
router-link.nav-item.dropdown(tag="li", to="/help", :class="{'active': $route.path.startsWith('/help')}", :to="{name: 'faq'}")
@@ -226,6 +234,7 @@ export default {
user: userIcon,
logo,
}),
+ groupPlans: [],
};
},
computed: {
@@ -234,6 +243,9 @@ export default {
}),
...mapState({user: 'user.data'}),
},
+ mounted () {
+ this.getUserGroupPlans();
+ },
methods: {
logout () {
localStorage.removeItem('habit-mobile-settings');
@@ -246,6 +258,9 @@ export default {
this.$store.state.avatarEditorOptions.editingUser = true;
this.$root.$emit('show::modal', 'avatar-modal');
},
+ async getUserGroupPlans () {
+ this.groupPlans = await this.$store.dispatch('guilds:getGroupPlans');
+ },
},
};
diff --git a/website/client/components/auth/registerLogin.vue b/website/client/components/auth/registerLogin.vue
index edca356e5b..c102ad19b6 100644
--- a/website/client/components/auth/registerLogin.vue
+++ b/website/client/components/auth/registerLogin.vue
@@ -194,9 +194,9 @@ export default {
},
mounted () {
hello.init({
- facebook: '',
+ facebook: process.env.FACEBOOK_KEY, // eslint-disable-line
// windows: WINDOWS_CLIENT_ID,
- google: '',
+ google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},
methods: {
diff --git a/website/client/components/group-plans/index.vue b/website/client/components/group-plans/index.vue
new file mode 100644
index 0000000000..037f2fdd1a
--- /dev/null
+++ b/website/client/components/group-plans/index.vue
@@ -0,0 +1,22 @@
+
+.row
+ secondary-menu.col-12
+ router-link.nav-link(:to="{name: 'groupPlanDetailTaskInformation', params: {groupId}}",
+ exact, :class="{'active': $route.name === 'groupPlanDetailTaskInformation'}") Task Board
+ router-link.nav-link(:to="{name: 'groupPlanDetailInformation', params: {groupId}}",
+ exact, :class="{'active': $route.name === 'groupPlanDetailInformation'}") Group Information
+
+ .col-12
+ router-view
+
+
+
diff --git a/website/client/components/group-plans/taskInformation.vue b/website/client/components/group-plans/taskInformation.vue
new file mode 100644
index 0000000000..82393a124e
--- /dev/null
+++ b/website/client/components/group-plans/taskInformation.vue
@@ -0,0 +1,411 @@
+
+.standard-page
+ .row.tasks-navigation
+ .col-4
+ h1 Group's Tasks
+ // @TODO: Abstract to component?
+ .col-4
+ .input-group
+ input.form-control.input-search(type="text", :placeholder="$t('search')", v-model="searchText")
+ .filter-panel(v-if="isFilterPanelOpen")
+ .tags-category.d-flex(v-for="tagsType in tagsByType", v-if="tagsType.tags.length > 0", :key="tagsType.key")
+ .tags-header
+ strong(v-once) {{ $t(tagsType.key) }}
+ a.d-block(v-if="tagsType.key === 'tags' && !editingTags", @click="editTags()") {{ $t('editTags2') }}
+ .tags-list.container
+ .row(:class="{'no-gutters': !editingTags}")
+ template(v-if="editingTags && tagsType.key === 'tags'")
+ .col-6(v-for="(tag, tagIndex) in tagsSnap")
+ .inline-edit-input-group.tag-edit-item.input-group
+ input.tag-edit-input.inline-edit-input.form-control(type="text", :value="tag.name")
+ span.input-group-btn(@click="removeTag(tagIndex)")
+ .svg-icon.destroy-icon(v-html="icons.destroy")
+ .col-6
+ input.new-tag-item.edit-tag-item.inline-edit-input.form-control(type="text", :placeholder="$t('newTag')", @keydown.enter="addTag($event)", v-model="newTag")
+ template(v-else)
+ .col-6(v-for="(tag, tagIndex) in tagsType.tags")
+ label.custom-control.custom-checkbox
+ input.custom-control-input(
+ type="checkbox",
+ :checked="isTagSelected(tag)",
+ @change="toggleTag(tag)",
+ )
+ span.custom-control-indicator
+ span.custom-control-description {{ tag.name }}
+
+ .filter-panel-footer.clearfix
+ template(v-if="editingTags === true")
+ .text-center
+ a.mr-3.btn-filters-primary(@click="saveTags()", v-once) {{ $t('saveEdits') }}
+ a.btn-filters-secondary(@click="cancelTagsEditing()", v-once) {{ $t('cancel') }}
+ template(v-else)
+ .float-left
+ a.btn-filters-danger(@click="resetFilters()", v-once) {{ $t('resetFilters') }}
+ .float-right
+ a.mr-3.btn-filters-primary(@click="applyFilters()", v-once) {{ $t('applyFilters') }}
+ a.btn-filters-secondary(@click="closeFilterPanel()", v-once) {{ $t('cancel') }}
+ span.input-group-btn
+ button.btn.btn-secondary.filter-button(
+ type="button",
+ @click="toggleFilterPanel()",
+ :class="{'filter-button-open': selectedTags.length > 0}",
+ )
+ .d-flex.align-items-center
+ span(v-once) {{ $t('filter') }}
+ .svg-icon.filter-icon(v-html="icons.filter")
+ #create-dropdown.col-1.offset-3
+ b-dropdown(:right="true", :variant="'success'")
+ div(slot="button-content")
+ .svg-icon.positive(v-html="icons.positive")
+ | {{ $t('create') }}
+ b-dropdown-item(v-for="type in columns", :key="type", @click="createTask(type)")
+ span.dropdown-icon-item(v-once)
+ span.svg-icon.inline(v-html="icons[type]")
+ span.text {{$t(type)}}
+ task-modal(
+ :task="workingTask",
+ :purpose="taskFormPurpose",
+ @cancel="cancelTaskModal()",
+ ref="taskModal",
+ :groupId="groupId",
+ v-on:taskCreated='taskCreated',
+ v-on:taskEdited='taskEdited',
+ )
+ .row
+ task-column.col-3(
+ v-for="column in columns",
+ :type="column",
+ :key="column",
+ :taskListOverride='tasksByType[column]',
+ v-on:editTask="editTask",
+ :group='group',
+ :searchText="searchText")
+
+
+
+
+
diff --git a/website/client/components/groups/groupPlan.vue b/website/client/components/groups/groupPlan.vue
index c4b74b3e3d..2ab181a176 100644
--- a/website/client/components/groups/groupPlan.vue
+++ b/website/client/components/groups/groupPlan.vue
@@ -1,5 +1,6 @@
div
+ amazon-payments-modal(:amazon-payments='amazonPayments')
div(v-if='activePage === PAGES.BENEFITS')
.header
h1.text-center Need more for your Group?
@@ -75,9 +76,9 @@ div
input(type='checkbox', v-model='newGroup.leaderOnly.challenges')
| {{ $t('leaderOnlyChallenges') }}
.form-group(v-if='type === "party"')
- input.btn.btn-default.form-control(type='submit', :value="$t('create')")
+ button.btn.btn-default.form-control(@click='pay()', :value="$t('create')")
.form-group
- button.btn.btn-primary.btn-lg.btn-block(@click="upgrade()", :disabled="!newGroupIsReady") {{ $t('create') }}
+ button.btn.btn-primary.btn-lg.btn-block(@click="pay()", :disabled="!newGroupIsReady") {{ $t('create') }}
diff --git a/website/client/components/tasks/approvalFooter.vue b/website/client/components/tasks/approvalFooter.vue
new file mode 100644
index 0000000000..7690a38412
--- /dev/null
+++ b/website/client/components/tasks/approvalFooter.vue
@@ -0,0 +1,99 @@
+
+div
+ approval-modal(:task='task')
+ .claim-bottom-message.col-12
+ .task-unclaimed(v-if='!approvalRequested && !multipleApprovalsRequested')
+ | {{ message }}
+ a(@click='claim()', v-if='!userIsAssigned') Claim
+ a(@click='unassign()', v-if='userIsAssigned') Remove Claim
+ .row.task-single-approval(v-if='approvalRequested')
+ .col-6.text-center
+ a(@click='approve()') Approve Task
+ .col-6.text-center
+ a Needs work
+ .text-center.task-multi-approval(v-if='multipleApprovalsRequested')
+ a(@click='showRequests()') View Requests
+
+
+
+
+
diff --git a/website/client/components/tasks/approvalHeader.vue b/website/client/components/tasks/approvalHeader.vue
new file mode 100644
index 0000000000..ca5b6fd542
--- /dev/null
+++ b/website/client/components/tasks/approvalHeader.vue
@@ -0,0 +1,38 @@
+
+.claim-bottom-message.col-12.text-center(v-if='task.approvals && task.approvals.length > 0', :class="{approval: userIsAdmin}")
+ .task-unclaimed
+ | {{ message }}
+
+
+
+
+
diff --git a/website/client/components/tasks/approvalModal.vue b/website/client/components/tasks/approvalModal.vue
new file mode 100644
index 0000000000..d968d51a01
--- /dev/null
+++ b/website/client/components/tasks/approvalModal.vue
@@ -0,0 +1,44 @@
+
+ b-modal#approval-modal(title="Approve Task", size='md', :hide-footer="true")
+ .modal-body
+ .row.approval(v-for='(approval, index) in task.approvals')
+ .col-8
+ strong {{approval.userId.profile.name}}
+ .col-2
+ button.btn.btn-primary(@click='approve(index)') Approve
+ .modal-footer
+ button.btn.btn-secondary(@click='close()') {{$t('close')}}
+
+
+
+
+
diff --git a/website/client/components/tasks/column.vue b/website/client/components/tasks/column.vue
index 6ad6b75b25..961b198f14 100644
--- a/website/client/components/tasks/column.vue
+++ b/website/client/components/tasks/column.vue
@@ -16,6 +16,7 @@
v-if="filterTask(task)",
:isUser="isUser",
@editTask="editTask",
+ :group='group',
)
template(v-if="isUser === true && type === 'reward' && activeFilter.label !== 'custom'")
.reward-items
@@ -169,7 +170,7 @@ export default {
bModal,
shopItem,
},
- props: ['type', 'isUser', 'searchText', 'selectedTags', 'taskListOverride'],
+ props: ['type', 'isUser', 'searchText', 'selectedTags', 'taskListOverride', 'group'], // @TODO: maybe we should store the group on state?
data () {
const types = Object.freeze({
habit: {
@@ -227,6 +228,7 @@ export default {
userPreferences: 'user.data.preferences',
}),
taskList () {
+ // @TODO: This should not default to user's tasks. It should require that you pass options in
if (this.taskListOverride) return this.taskListOverride;
return this.tasks[`${this.type}s`];
},
@@ -265,6 +267,8 @@ export default {
combinedTasksHeights += el.offsetHeight;
});
+ if (!this.$refs.columnBackground) return;
+
const rewardsList = taskListEl.getElementsByClassName('reward-items')[0];
if (rewardsList) {
combinedTasksHeights += rewardsList.offsetHeight;
diff --git a/website/client/components/tasks/task.vue b/website/client/components/tasks/task.vue
index 4a0433b1a2..c5df8352a1 100644
--- a/website/client/components/tasks/task.vue
+++ b/website/client/components/tasks/task.vue
@@ -1,277 +1,281 @@
-.task.d-flex(:class="{'task-not-scoreable': isUser !== true}")
- // Habits left side control
- .left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.up")
- .task-control.habit-control(:class="controlClass.up + '-control-habit'", @click="isUser ? score('up') : null")
- .svg-icon.positive(v-html="icons.positive")
- // Dailies and todos left side control
- .left-control.d-flex.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="controlClass")
- .task-control.daily-todo-control(:class="controlClass + '-control-daily-todo'", @click="isUser ? score(task.completed ? 'down' : 'up') : null")
- .svg-icon.check(v-html="icons.check", :class="{'display-check-icon': task.completed}")
- // Task title, description and icons
- .task-content(:class="contentClass")
- .task-clickable-area(@click="edit($event, task)")
- h3.task-title(:class="{ 'has-notes': task.notes }", v-markdown="task.text")
- .task-notes.small-text(v-markdown="task.notes")
- .checklist(v-if="task.checklist && task.checklist.length > 0")
- label.custom-control.custom-checkbox.checklist-item(
- v-for="item in task.checklist", :class="{'checklist-item-done': item.completed}",
- )
- input.custom-control-input(type="checkbox", :checked="item.completed", @change="toggleChecklistItem(item)")
- span.custom-control-indicator
- span.custom-control-description {{ item.text }}
- .icons.small-text.d-flex.align-items-center
- .d-flex.align-items-center(v-if="task.type === 'todo' && task.date", :class="{'due-overdue': isDueOverdue}")
- .svg-icon.calendar(v-html="icons.calendar")
- span {{dueIn}}
- .icons-right.d-flex.justify-content-end
- .d-flex.align-items-center(v-if="showStreak")
- .svg-icon.streak(v-html="icons.streak")
- span(v-if="task.type === 'daily'") {{task.streak}}
- span(v-if="task.type === 'habit'")
- span.m-0(v-if="task.up") +{{task.counterUp}}
- span.m-0(v-if="task.up && task.down") |
- span.m-0(v-if="task.down") -{{task.counterDown}}
- .d-flex.align-items-center(v-if="task.challenge && task.challenge.id")
- .svg-icon.challenge(v-html="icons.challenge")
- b-popover.tags-popover.no-span-margin(
- :triggers="['hover']",
- :placement="'bottom'",
- :popover-style="{'max-width': '1000px'}",
+.task
+ approval-header(:task='task', v-if='this.task.group.id', :group='group')
+ .d-flex(:class="{'task-not-scoreable': isUser !== true}")
+ // Habits left side control
+ .left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.up")
+ .task-control.habit-control(:class="controlClass.up + '-control-habit'", @click="isUser ? score('up') : null")
+ .svg-icon.positive(v-html="icons.positive")
+ // Dailies and todos left side control
+ .left-control.d-flex.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="controlClass")
+ .task-control.daily-todo-control(:class="controlClass + '-control-daily-todo'", @click="isUser ? score(task.completed ? 'down' : 'up') : null")
+ .svg-icon.check(v-html="icons.check", :class="{'display-check-icon': task.completed}")
+ // Task title, description and icons
+ .task-content(:class="contentClass")
+ .task-clickable-area(@click="edit($event, task)")
+ h3.task-title(:class="{ 'has-notes': task.notes }", v-markdown="task.text")
+ .task-notes.small-text(v-markdown="task.notes")
+ .checklist(v-if="task.checklist && task.checklist.length > 0")
+ label.custom-control.custom-checkbox.checklist-item(
+ v-for="item in task.checklist", :class="{'checklist-item-done': item.completed}",
)
- .d-flex.align-items-center(slot="content")
- .tags-popover-title(v-once) {{ `${$t('tags')}:` }}
- .tag-label(v-for="tag in getTagsFor(task)") {{tag}}
- .d-flex.align-items-center(v-if="task.tags && task.tags.length > 0")
- .svg-icon.tags(v-html="icons.tags")
+ input.custom-control-input(type="checkbox", :checked="item.completed", @change="toggleChecklistItem(item)")
+ span.custom-control-indicator
+ span.custom-control-description {{ item.text }}
+ .icons.small-text.d-flex.align-items-center
+ .d-flex.align-items-center(v-if="task.type === 'todo' && task.date", :class="{'due-overdue': isDueOverdue}")
+ .svg-icon.calendar(v-html="icons.calendar")
+ span {{dueIn}}
+ .icons-right.d-flex.justify-content-end
+ .d-flex.align-items-center(v-if="showStreak")
+ .svg-icon.streak(v-html="icons.streak")
+ span(v-if="task.type === 'daily'") {{task.streak}}
+ span(v-if="task.type === 'habit'")
+ span.m-0(v-if="task.up") +{{task.counterUp}}
+ span.m-0(v-if="task.up && task.down") |
+ span.m-0(v-if="task.down") -{{task.counterDown}}
+ .d-flex.align-items-center(v-if="task.challenge && task.challenge.id")
+ .svg-icon.challenge(v-html="icons.challenge")
+ b-popover.tags-popover.no-span-margin(
+ :triggers="['hover']",
+ :placement="'bottom'",
+ :popover-style="{'max-width': '1000px'}",
+ )
+ .d-flex.align-items-center(slot="content")
+ .tags-popover-title(v-once) {{ `${$t('tags')}:` }}
+ .tag-label(v-for="tag in getTagsFor(task)") {{tag}}
+ .d-flex.align-items-center(v-if="task.tags && task.tags.length > 0")
+ .svg-icon.tags(v-html="icons.tags")
- // Habits right side control
- .right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.down")
- .task-control.habit-control(:class="controlClass.down + '-control-habit'", @click="isUser ? score('down') : null")
- .svg-icon.negative(v-html="icons.negative")
- // Rewards right side control
- .right-control.d-flex.align-items-center.justify-content-center.reward-control(v-if="task.type === 'reward'", :class="controlClass", @click="isUser ? score('down') : null")
- .svg-icon(v-html="icons.gold")
- .small-text {{task.value}}
+ // Habits right side control
+ .right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.down")
+ .task-control.habit-control(:class="controlClass.down + '-control-habit'", @click="isUser ? score('down') : null")
+ .svg-icon.negative(v-html="icons.negative")
+ // Rewards right side control
+ .right-control.d-flex.align-items-center.justify-content-center.reward-control(v-if="task.type === 'reward'", :class="controlClass", @click="isUser ? score('down') : null")
+ .svg-icon(v-html="icons.gold")
+ .small-text {{task.value}}
+ approval-footer(:task='task', v-if='this.task.group.id', :group='group')
-
diff --git a/website/client/mixins/payments.js b/website/client/mixins/payments.js
new file mode 100644
index 0000000000..8009564c2c
--- /dev/null
+++ b/website/client/mixins/payments.js
@@ -0,0 +1,105 @@
+import axios from 'axios';
+
+const STRIPE_PUB_KEY = process.env.STRIPE_PUB_KEY; // eslint-disable-line
+import subscriptionBlocks from '../../common/script/content/subscriptionBlocks';
+
+export default {
+ methods: {
+ showStripe (data) {
+ if (!this.checkGemAmount(data)) return;
+
+ let sub = false;
+
+ if (data.subscription) {
+ sub = data.subscription;
+ } else if (data.gift && data.gift.type === 'subscription') {
+ sub = data.gift.subscription.key;
+ }
+
+ sub = sub && subscriptionBlocks[sub];
+
+ let amount = 500;// 500 = $5
+ if (sub) amount = sub.price * 100;
+ if (data.gift && data.gift.type === 'gems') amount = data.gift.gems.amount / 4 * 100;
+ if (data.group) amount = (sub.price + 3 * (data.group.memberCount - 1)) * 100;
+
+ this.StripeCheckout.open({
+ key: STRIPE_PUB_KEY,
+ address: false,
+ amount,
+ name: 'Habitica',
+ description: sub ? this.$t('subscribe') : this.$t('checkout'),
+ image: '/apple-touch-icon-144-precomposed.png',
+ panelLabel: sub ? this.$t('subscribe') : this.$t('checkout'),
+ token: async (res) => {
+ let url = '/stripe/checkout?a=a'; // just so I can concat &x=x below
+
+ if (data.groupToCreate) {
+ url = '/api/v3/groups/create-plan?a=a';
+ res.groupToCreate = data.groupToCreate;
+ res.paymentType = 'Stripe';
+ }
+
+ if (data.gift) url += `&gift=${this.encodeGift(data.uuid, data.gift)}`;
+ if (data.subscription) url += `&sub=${sub.key}`;
+ if (data.coupon) url += `&coupon=${data.coupon}`;
+ if (data.groupId) url += `&groupId=${data.groupId}`;
+
+ let response = await axios.post(url, res);
+
+ let responseStatus = response.status;
+ if (responseStatus >= 400) {
+ alert(`Error: ${response.message}`);
+ return;
+ }
+
+ let newGroup = response.data.data;
+ if (newGroup && newGroup._id) {
+ // @TODO: Just append? or $emit?
+ this.$router.push(`/group-plans/${newGroup._id}/task-information`);
+ return;
+ }
+
+ window.location.reload(true);
+ },
+ });
+ },
+ checkGemAmount (data) {
+ let isGem = data && data.gift && data.gift.type === 'gems';
+ let notEnoughGem = isGem && (!data.gift.gems.amount || data.gift.gems.amount === 0);
+ if (notEnoughGem) {
+ Notification.error(this.$t('badAmountOfGemsToPurchase'), true);
+ return false;
+ }
+ return true;
+ },
+ amazonPaymentsInit (data) {
+ // @TODO: Do we need this? if (!this.isAmazonReady) return;
+ if (!this.checkGemAmount(data)) return;
+ if (data.type !== 'single' && data.type !== 'subscription') return;
+
+ if (data.gift) {
+ if (data.gift.gems && data.gift.gems.amount && data.gift.gems.amount <= 0) return;
+ data.gift.uuid = data.giftedTo;
+ }
+
+ if (data.subscription) {
+ this.amazonPayments.subscription = data.subscription;
+ this.amazonPayments.coupon = data.coupon;
+ }
+
+ if (data.groupId) {
+ this.amazonPayments.groupId = data.groupId;
+ }
+
+ if (data.groupToCreate) {
+ this.amazonPayments.groupToCreate = data.groupToCreate;
+ }
+
+ this.amazonPayments.gift = data.gift;
+ this.amazonPayments.type = data.type;
+
+ this.$root.$emit('show::modal', 'amazon-payment');
+ },
+ },
+};
diff --git a/website/client/router.js b/website/client/router.js
index 19433a379d..7d289bc042 100644
--- a/website/client/router.js
+++ b/website/client/router.js
@@ -71,6 +71,10 @@ const GuildsDiscoveryPage = () => import(/* webpackChunkName: "guilds" */ './com
const GuildPage = () => import(/* webpackChunkName: "guilds" */ './components/groups/guild');
const GroupPlansAppPage = () => import(/* webpackChunkName: "guilds" */ './components/groups/groupPlan');
+// Group Plans
+const GroupPlanIndex = () => import(/* webpackChunkName: "group-plans" */ './components/group-plans/index');
+const GroupPlanTaskInformation = () => import(/* webpackChunkName: "group-plans" */ './components/group-plans/taskInformation');
+
// Challenges
const ChallengeIndex = () => import(/* webpackChunkName: "challenges" */ './components/challenges/index');
const MyChallenges = () => import(/* webpackChunkName: "challenges" */ './components/challenges/myChallenges');
@@ -122,6 +126,26 @@ const router = new VueRouter({
},
{ name: 'party', path: '/party', component: GuildPage },
{ name: 'groupPlan', path: '/group-plans', component: GroupPlansAppPage },
+ {
+ name: 'groupPlanDetail',
+ path: '/group-plans/:groupId',
+ component: GroupPlanIndex,
+ props: true,
+ children: [
+ {
+ name: 'groupPlanDetailTaskInformation',
+ path: '/group-plans/:groupId/task-information',
+ component: GroupPlanTaskInformation,
+ props: true,
+ },
+ {
+ name: 'groupPlanDetailInformation',
+ path: '/group-plans/:groupId/information',
+ component: GuildPage,
+ props: true,
+ },
+ ],
+ },
{
path: '/groups',
component: GuildIndex,
diff --git a/website/client/store/actions/guilds.js b/website/client/store/actions/guilds.js
index bf7986e7e0..89532f0fdd 100644
--- a/website/client/store/actions/guilds.js
+++ b/website/client/store/actions/guilds.js
@@ -165,3 +165,8 @@ export async function removeManager (store, payload) {
return response;
}
+
+export async function getGroupPlans () {
+ let response = await axios.get('/api/v3/group-plans');
+ return response.data.data;
+}
diff --git a/website/client/store/actions/tasks.js b/website/client/store/actions/tasks.js
index f49d339be5..7a9d61b8cb 100644
--- a/website/client/store/actions/tasks.js
+++ b/website/client/store/actions/tasks.js
@@ -143,3 +143,33 @@ export async function createChallengeTasks (store, payload) {
let response = await axios.post(`/api/v3/tasks/challenge/${payload.challengeId}`, payload.tasks);
return response.data.data;
}
+
+export async function getGroupTasks (store, payload) {
+ let response = await axios.get(`/api/v3/tasks/group/${payload.groupId}`);
+ return response.data.data;
+}
+
+export async function createGroupTasks (store, payload) {
+ let response = await axios.post(`/api/v3/tasks/group/${payload.groupId}`, payload.tasks);
+ return response.data.data;
+}
+
+export async function assignTask (store, payload) {
+ let response = await axios.post(`/api/v3/tasks/${payload.taskId}/assign/${payload.userId}`);
+ return response.data.data;
+}
+
+export async function unassignTask (store, payload) {
+ let response = await axios.post(`/api/v3/tasks/${payload.taskId}/unassign/${payload.userId}`);
+ return response.data.data;
+}
+
+export async function getGroupApprovals (store, payload) {
+ let response = await axios.get(`/api/v3/approvals/group/${payload.groupId}`);
+ return response.data.data;
+}
+
+export async function approve (store, payload) {
+ let response = await axios.post(`/api/v3/tasks/${payload.taskId}/approve/${payload.userId}`);
+ return response.data.data;
+}
diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js
index d52ee141ea..c3aaeb5858 100644
--- a/website/server/controllers/api-v3/groups.js
+++ b/website/server/controllers/api-v3/groups.js
@@ -1248,4 +1248,41 @@ api.removeGroupManager = {
},
};
+/**
+ * @api {get} /api/v3/group-plans Get group plans for a user
+ * @apiName GetGroupPlans
+ * @apiGroup Group
+ *
+ * @apiSuccess {Object[]} data An array of the requested groups with a group plan (See /website/server/models/group.js)
+ *
+ * @apiSuccessExample {json} Groups the user is in with a group plan:
+ * HTTP/1.1 200 OK
+ * [
+ * {groupPlans}
+ * ]
+ */
+api.getGroupPlans = {
+ method: 'GET',
+ url: '/group-plans',
+ middlewares: [authWithHeaders()],
+ async handler (req, res) {
+ let user = res.locals.user;
+
+ const userGroups = user.getGroups();
+
+ const groups = await Group
+ .find({
+ _id: {$in: userGroups},
+ })
+ .select('leaderOnly leader purchased name')
+ .exec();
+
+ let groupPlans = groups.filter(group => {
+ return group.isSubscribed();
+ });
+
+ res.respond(200, groupPlans);
+ },
+};
+
module.exports = api;
diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js
index 9cfe8b6c39..119994c8c0 100644
--- a/website/server/controllers/api-v3/tasks.js
+++ b/website/server/controllers/api-v3/tasks.js
@@ -454,6 +454,7 @@ api.updateTask = {
// repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
// see https://github.com/Automattic/mongoose/issues/2749
+ task.group.approval.required = false;
if (sanitizedObj.requiresApproval) {
task.group.approval.required = true;
}