From eb43f83c714fb3faa85b156683f21a510a0102c3 Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Mon, 14 Aug 2017 13:19:41 -0600 Subject: [PATCH] New client group plan (#8948) * Added stripe payment for group plan * Began adding amazon * Added amazon payments for group * Added get group plans route * Added group plan nav * Added initial task page * Added create and edit group plans * Added initial approval header and footer * Added assignment and approved requirement * Added minor text fixes * Added inital approval flow * Added approval modal * Removed always true * Added more styles for filters * Added search * Added env vars * Fixed router issues * Added env to social login * Fixed merge conflict --- .../groups/GET-group-plans.test.js | 32 ++ webpack/config/prod.env.js | 45 +- website/client/components/appMenu.vue | 17 +- .../client/components/auth/registerLogin.vue | 4 +- .../client/components/group-plans/index.vue | 22 + .../group-plans/taskInformation.vue | 411 ++++++++++++++++ .../client/components/groups/groupPlan.vue | 43 +- .../components/payments/amazonModal.vue | 218 +++++--- .../components/settings/subscription.vue | 91 +--- .../components/tasks/approvalFooter.vue | 99 ++++ .../components/tasks/approvalHeader.vue | 38 ++ .../client/components/tasks/approvalModal.vue | 44 ++ website/client/components/tasks/column.vue | 6 +- website/client/components/tasks/task.vue | 464 +++++++++--------- website/client/components/tasks/taskModal.vue | 310 +++++++----- website/client/mixins/payments.js | 105 ++++ website/client/router.js | 24 + website/client/store/actions/guilds.js | 5 + website/client/store/actions/tasks.js | 30 ++ website/server/controllers/api-v3/groups.js | 37 ++ website/server/controllers/api-v3/tasks.js | 1 + 21 files changed, 1522 insertions(+), 524 deletions(-) create mode 100644 test/api/v3/integration/groups/GET-group-plans.test.js create mode 100644 website/client/components/group-plans/index.vue create mode 100644 website/client/components/group-plans/taskInformation.vue create mode 100644 website/client/components/tasks/approvalFooter.vue create mode 100644 website/client/components/tasks/approvalHeader.vue create mode 100644 website/client/components/tasks/approvalModal.vue create mode 100644 website/client/mixins/payments.js 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 @@ + + + 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 @@ + + + + + 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 @@ 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ - 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; }