Thehollidayinn/group plans part 2 (#8262)

* Added all ui components back

* Added group ui items back and initial group approval directive

* Added approval list view with approving functionality

* Added notification display for group approvals

* Fixed linting issues

* Removed expectation from beforeEach

* Moved string to locale

* Added per use group plan for stripe

* Added tests for stripe group plan upgrade

* Removed paypal option

* Abstract sub blocks. Hit group sub block from user settings page. Added group subscriptin beneifts display

* Fixed lint issue

* Added pricing and adjusted styles

* Moved text to translations

* Added group email types

* Fixed typo

* Fixed group plan abstraction and other style issues

* Fixed email unit test

* Added type to group plan to filter our group plans

* Removed dev protection from routes

* Removed hard coding and fixed upgrade plan

* Added error when group has subscription and tries to remove

* Fixed payment unit tests

* Added custom string and moved subscription check up in the logic

* Added ability for old leader to delete subscription the created

* Allowed old guild leader to edit their group subscription

* Fixed linting and tests

* Added group sub page to user sub settings

* Added approval and group tasks requests back. Hid user group sub on profile

* Added group tasks sync after adding to allow for editing

* Fixed promise chain when resolving group

* Added approvals to group promise chain

* Ensured compelted group todos are not delted at cron

* Updated copy and other minor styles

* Added group field to tags and recolored group tag.

* Added chat message when task is claimed

* Preventing task scoring when approval is needed

* Added approval requested indicator

* Updated column with for tasks on group page

* Added checklist sync on assign

* Added sync for checklist items

* Added checkilist sync when task is updated

* Added checklist sync remove

* Sanatized group tasks when updated

* Fixed lint issues

* Added instant scoring of approved task

* Added task modal

* Fixed editing of challenge and group tasks

* Added cancel button

* Added add new checklist option to update sync

* Added remove for checklist

* Added checklist update

* Added difference check and sync for checklist if there is a diff

* Fixed task syncing

* Fixed linting issues

* Fixed styles and karma tests

* Fixed minor style issues

* Fixed obj transfer on scope

* Fixed broken tests

* Added new benefits page

* Updated group page styles

* Updated benefits page style

* Added translations

* Prevented sync with empty trask list

* Added task title to edit modal

* Added new group plans page and upgrade redirect

* Added group plans redirect to upgrade

* Fixed party home page being hidden and home button click

* Fixed dynamic changing of task status and grey popup

* Fixed tag editing

* Hid benifites information if group has subscription

* Added quotes to task name

* Fixed issue with assigning multiple users

* Added new group plans ctrl

* Hid menu from public guilds

* Fixed task sync issue

* Updated placeholder for assign field

* Added correct cost to subscribe details

* Hid create, edit, delete task options from non group leaders

* Prevented some front end modifications to group tasks

* Hid tags option from group original task

* Added refresh for approvals and group tasks

* Prepend new group tasks

* Fix last checklist item sync

* Fixed casing issue with tags

* Added claimed by message on hover

* Prevent user from deleting assigned task

* Added single route for group plan sign up and payments

* Abstracted stripe payments and added initial tests

* Abstracted amazon and added initial tests

* Fixed create group message

* Update group id check and return group

* Updated to use the new returned group

* Fixed linting and promise issues

* Fixed broken leave test after merge issue

* Fixed undefined approval error and editing/deleting challenge tasks

* Add pricing to group plans, removed confirmation, and fixed redirect after payment

* Updated group plan cost text
This commit is contained in:
Keith Holliday
2016-12-21 13:45:45 -06:00
committed by GitHub
parent 55a8eef3e1
commit ea24eeb019
70 changed files with 2002 additions and 736 deletions

View File

@@ -207,6 +207,7 @@
"selenium-server": "2.53.0", "selenium-server": "2.53.0",
"sinon": "^1.17.2", "sinon": "^1.17.2",
"sinon-chai": "^2.8.0", "sinon-chai": "^2.8.0",
"sinon-stub-promise": "^4.0.0",
"superagent-defaults": "^0.1.13", "superagent-defaults": "^0.1.13",
"vinyl-transform": "^1.0.0", "vinyl-transform": "^1.0.0",
"webpack-dev-middleware": "^1.4.0", "webpack-dev-middleware": "^1.4.0",

View File

@@ -69,4 +69,16 @@ describe('DELETE /tasks/:id', () => {
expect(syncedTask.group.broken).to.equal('TASK_DELETED'); expect(syncedTask.group.broken).to.equal('TASK_DELETED');
expect(member2SyncedTask.group.broken).to.equal('TASK_DELETED'); expect(member2SyncedTask.group.broken).to.equal('TASK_DELETED');
}); });
it('prevents a user from deleting a task they are assigned to', async () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(member.del(`/tasks/${syncedTask._id}`))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('cantDeleteAssignedGroupTasks'),
});
});
}); });

View File

@@ -59,9 +59,11 @@ describe('POST /tasks/:id/approve/:userId', () => {
await member.sync(); await member.sync();
expect(member.notifications.length).to.equal(1); expect(member.notifications.length).to.equal(2);
expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED'); expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED');
expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved')); expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(member.notifications[1].type).to.equal('SCORED_TASK');
expect(member.notifications[1].data.message).to.equal(t('yourTaskHasBeenApproved', {taskText: task.text}));
expect(syncedTask.group.approval.approved).to.be.true; expect(syncedTask.group.approval.approved).to.be.true;
expect(syncedTask.group.approval.approvingUser).to.equal(user._id); expect(syncedTask.group.approval.approvingUser).to.equal(user._id);

View File

@@ -82,7 +82,7 @@ describe('POST /tasks/:taskId', () => {
}); });
}); });
it('allows user to assign themselves', async () => { it('allows user to assign themselves (claim)', async () => {
await member.post(`/tasks/${task._id}/assign/${member._id}`); await member.post(`/tasks/${task._id}/assign/${member._id}`);
let groupTask = await user.get(`/tasks/group/${guild._id}`); let groupTask = await user.get(`/tasks/group/${guild._id}`);
@@ -93,6 +93,14 @@ describe('POST /tasks/:taskId', () => {
expect(syncedTask).to.exist; expect(syncedTask).to.exist;
}); });
it('sends a message to the group when a user claims a task', async () => {
await member.post(`/tasks/${task._id}/assign/${member._id}`);
let updateGroup = await user.get(`/groups/${guild._id}`);
expect(updateGroup.chat[0].text).to.equal(t('userIsClamingTask', {username: member.profile.name, task: task.text}));
});
it('assigns a task to a user', async () => { it('assigns a task to a user', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`); await user.post(`/tasks/${task._id}/assign/${member._id}`);

View File

@@ -4,15 +4,20 @@ import analytics from '../../../../../website/server/libs/analyticsService';
import notifications from '../../../../../website/server/libs/pushNotifications'; import notifications from '../../../../../website/server/libs/pushNotifications';
import { model as User } from '../../../../../website/server/models/user'; import { model as User } from '../../../../../website/server/models/user';
import { model as Group } from '../../../../../website/server/models/group'; import { model as Group } from '../../../../../website/server/models/group';
import stripeModule from 'stripe';
import moment from 'moment'; import moment from 'moment';
import { translate as t } from '../../../../helpers/api-v3-integration.helper'; import { translate as t } from '../../../../helpers/api-v3-integration.helper';
import { import {
generateGroup, generateGroup,
} from '../../../../helpers/api-unit.helper.js'; } from '../../../../helpers/api-unit.helper.js';
import i18n from '../../../../../website/common/script/i18n';
import amzLib from '../../../../../website/server/libs/amazonPayments';
describe('payments/index', () => { describe('payments/index', () => {
let user, group, data, plan; let user, group, data, plan;
let stripe = stripeModule('test');
beforeEach(async () => { beforeEach(async () => {
user = new User(); user = new User();
user.profile.name = 'sender'; user.profile.name = 'sender';
@@ -625,7 +630,40 @@ describe('payments/index', () => {
await api.cancelSubscription(data); await api.cancelSubscription(data);
expect(sender.sendTxn).to.be.calledOnce; expect(sender.sendTxn).to.be.calledOnce;
expect(sender.sendTxn).to.be.calledWith(user, 'cancel-subscription'); expect(sender.sendTxn).to.be.calledWith(user, 'group-cancel-subscription');
});
it('prevents non group leader from manging subscription', async () => {
let groupMember = new User();
data.user = groupMember;
data.groupId = group._id;
await expect(api.cancelSubscription(data))
.eventually.be.rejected.and.to.eql({
httpCode: 401,
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
name: 'NotAuthorized',
});
});
it('allows old group leader to cancel if they created the subscription', async () => {
data.groupId = group._id;
data.sub = {
key: 'group_monthly',
};
data.paymentMethod = 'Payment Method';
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
let newLeader = new User();
updatedGroup.leader = newLeader._id;
await updatedGroup.save();
await api.cancelSubscription(data);
updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.dateTerminated).to.exist;
}); });
}); });
}); });
@@ -732,4 +770,156 @@ describe('payments/index', () => {
}); });
}); });
}); });
describe('#upgradeGroupPlan', () => {
let spy;
beforeEach(function () {
spy = sinon.stub(stripe.subscriptions, 'update');
spy.returnsPromise().resolves([]);
data.groupId = group._id;
data.sub.quantity = 3;
});
afterEach(function () {
sinon.restore(stripe.subscriptions.update);
});
it('updates a group plan quantity', async () => {
data.paymentMethod = 'Stripe';
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
updatedGroup.memberCount += 1;
await updatedGroup.save();
await api.updateStripeGroupPlan(updatedGroup, stripe);
expect(spy.calledOnce).to.be.true;
expect(updatedGroup.purchased.plan.quantity).to.eql(4);
});
it('does not update a group plan quantity that has a payment method other than stripe', async () => {
await api.createSubscription(data);
let updatedGroup = await Group.findById(group._id).exec();
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
updatedGroup.memberCount += 1;
await updatedGroup.save();
await api.updateStripeGroupPlan(updatedGroup, stripe);
expect(spy.calledOnce).to.be.false;
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
});
});
describe('payWithStripe', () => {
let spy;
let stripeCreateCustomerSpy;
let createSubSpy;
beforeEach(function () {
spy = sinon.stub(stripe.subscriptions, 'update');
spy.returnsPromise().resolves;
stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create');
let stripCustomerResponse = {
subscriptions: {
data: [{id: 'test-id'}],
},
};
stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse);
createSubSpy = sinon.stub(api, 'createSubscription');
createSubSpy.returnsPromise().resolves({});
data.groupId = group._id;
data.sub.quantity = 3;
});
afterEach(function () {
sinon.restore(stripe.subscriptions.update);
stripe.customers.create.restore();
api.createSubscription.restore();
});
it('subscribes with stripe', async () => {
let token = 'test-token';
let gift;
let sub = data.sub;
let groupId = group._id;
let email = 'test@test.com';
let headers = {};
let coupon;
await api.payWithStripe([
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
], stripe);
expect(stripeCreateCustomerSpy.calledOnce).to.be.true;
expect(createSubSpy.calledOnce).to.be.true;
});
});
describe('subscribeWithAmazon', () => {
let amazonSetBillingAgreementDetailsSpy;
let amazonConfirmBillingAgreementSpy;
let amazongAuthorizeOnBillingAgreementSpy;
let createSubSpy;
beforeEach(function () {
amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails');
amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({});
amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement');
amazonConfirmBillingAgreementSpy.returnsPromise().resolves({});
amazongAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement');
amazongAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({});
createSubSpy = sinon.stub(api, 'createSubscription');
createSubSpy.returnsPromise().resolves({});
});
afterEach(function () {
amzLib.setBillingAgreementDetails.restore();
amzLib.confirmBillingAgreement.restore();
amzLib.authorizeOnBillingAgreement.restore();
api.createSubscription.restore();
});
it('subscribes with stripe', async () => {
let billingAgreementId = 'billingAgreementId';
let sub = data.sub;
let coupon;
let groupId = group._id;
let headers = {};
await api.subscribeWithAmazon([
billingAgreementId,
sub,
coupon,
sub,
user,
groupId,
headers,
]);
expect(amazonSetBillingAgreementDetailsSpy.calledOnce).to.be.true;
expect(amazonConfirmBillingAgreementSpy.calledOnce).to.be.true;
expect(amazongAuthorizeOnBillingAgreementSpy.calledOnce).to.be.true;
expect(createSubSpy.calledOnce).to.be.true;
});
});
}); });

View File

@@ -1,13 +1,16 @@
import { sleep } from '../../../../helpers/api-unit.helper'; import { sleep } from '../../../../helpers/api-unit.helper';
import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group'; import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user'; import { model as User } from '../../../../../website/server/models/user';
import { BadRequest } from '../../../../../website/server/libs/errors'; import {
BadRequest,
} from '../../../../../website/server/libs/errors';
import { quests as questScrolls } from '../../../../../website/common/script/content'; import { quests as questScrolls } from '../../../../../website/common/script/content';
import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook'; import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook';
import * as email from '../../../../../website/server/libs/email'; import * as email from '../../../../../website/server/libs/email';
import validator from 'validator'; import validator from 'validator';
import { TAVERN_ID } from '../../../../../website/common/script/'; import { TAVERN_ID } from '../../../../../website/common/script/';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import shared from '../../../../../website/common';
describe('Group Model', () => { describe('Group Model', () => {
let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember; let party, questLeader, participatingMember, nonParticipatingMember, undecidedMember;
@@ -638,6 +641,22 @@ describe('Group Model', () => {
expect(party).to.not.exist; expect(party).to.not.exist;
}); });
it('does not delete a private group when the last member leaves and a subscription is active', async () => {
party.memberCount = 1;
party.purchased.plan.customerId = '110002222333';
await expect(party.leave(participatingMember))
.to.eventually.be.rejected.and.to.eql({
name: 'NotAuthorized',
httpCode: 401,
message: shared.i18n.t('cannotDeleteActiveGroup'),
});
party = await Group.findOne({_id: party._id});
expect(party).to.exist;
expect(party.memberCount).to.eql(1);
});
it('does not delete a public group when the last member leaves', async () => { it('does not delete a public group when the last member leaves', async () => {
party.privacy = 'public'; party.privacy = 'public';

View File

@@ -2,7 +2,7 @@ import { model as Challenge } from '../../../../../website/server/models/challen
import { model as Group } from '../../../../../website/server/models/group'; import { model as Group } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user'; import { model as User } from '../../../../../website/server/models/user';
import * as Tasks from '../../../../../website/server/models/task'; import * as Tasks from '../../../../../website/server/models/task';
import { each, find } from 'lodash'; import { each, find, findIndex } from 'lodash';
describe('Group Task Methods', () => { describe('Group Task Methods', () => {
let guild, leader, challenge, task; let guild, leader, challenge, task;
@@ -68,11 +68,29 @@ describe('Group Task Methods', () => {
task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue)); task = new Tasks[`${taskType}`](Tasks.Task.sanitize(taskValue));
task.group.id = guild._id; task.group.id = guild._id;
await task.save(); await task.save();
if (task.checklist) {
task.checklist.push({
text: 'Checklist Item 1',
completed: false,
});
}
}); });
it('syncs an assigned task to a user', async () => { it('syncs an assigned task to a user', async () => {
await guild.syncTask(task, leader); await guild.syncTask(task, leader);
let updatedLeader = await User.findOne({_id: leader._id});
let tagIndex = findIndex(updatedLeader.tags, {id: guild._id});
let newTag = updatedLeader.tags[tagIndex];
expect(newTag.id).to.equal(guild._id);
expect(newTag.name).to.equal(guild.name);
expect(newTag.group).to.equal(guild._id);
});
it('create tags for a user when task is synced', async () => {
await guild.syncTask(task, leader);
let updatedLeader = await User.findOne({_id: leader._id}); let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask); let syncedTask = find(updatedLeadersTasks, findLinkedTask);
@@ -96,38 +114,124 @@ describe('Group Task Methods', () => {
expect(syncedTask.text).to.equal(task.text); expect(syncedTask.text).to.equal(task.text);
}); });
it('syncs updated info for assigned task to all users', async () => { it('syncs checklist items to an assigned user', async () => {
let newMember = new User({
guilds: [guild._id],
});
await newMember.save();
await guild.syncTask(task, leader); await guild.syncTask(task, leader);
await guild.syncTask(task, newMember);
let updatedTaskName = 'Update Task name';
task.text = updatedTaskName;
task.group.approval.required = true;
await guild.updateTask(task);
let updatedLeader = await User.findOne({_id: leader._id}); let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}}); let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask); let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id}); if (task.type !== 'daily' && task.type !== 'todo') return;
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(task.group.assignedUsers).to.contain(leader._id); expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask).to.exist; expect(syncedTask.checklist[0].text).to.equal(task.checklist[0].text);
expect(syncedTask.text).to.equal(task.text); });
expect(syncedTask.group.approval.required).to.equal(true);
expect(task.group.assignedUsers).to.contain(newMember._id); describe('syncs updated info', async() => {
expect(syncedMemberTask).to.exist; let newMember;
expect(syncedMemberTask.text).to.equal(task.text);
expect(syncedMemberTask.group.approval.required).to.equal(true); beforeEach(async () => {
newMember = new User({
guilds: [guild._id],
});
await newMember.save();
await guild.syncTask(task, leader);
await guild.syncTask(task, newMember);
});
it('syncs updated info for assigned task to all users', async () => {
let updatedTaskName = 'Update Task name';
task.text = updatedTaskName;
task.group.approval.required = true;
await guild.updateTask(task);
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(task.group.assignedUsers).to.contain(leader._id);
expect(syncedTask).to.exist;
expect(syncedTask.text).to.equal(task.text);
expect(syncedTask.group.approval.required).to.equal(true);
expect(task.group.assignedUsers).to.contain(newMember._id);
expect(syncedMemberTask).to.exist;
expect(syncedMemberTask.text).to.equal(task.text);
expect(syncedMemberTask.group.approval.required).to.equal(true);
});
it('syncs a new checklist item to all assigned users', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
let newCheckListItem = {
text: 'Checklist Item 1',
completed: false,
};
task.checklist.push(newCheckListItem);
await guild.updateTask(task, {newCheckListItem});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask.checklist[1].text).to.equal(task.checklist[1].text);
expect(syncedMemberTask.checklist.length).to.equal(task.checklist.length);
expect(syncedMemberTask.checklist[1].text).to.equal(task.checklist[1].text);
});
it('syncs updated info for checklist in assigned task to all users when flag is passed', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
let updateCheckListText = 'Updated checklist item';
if (task.checklist) {
task.checklist[0].text = updateCheckListText;
}
await guild.updateTask(task, {updateCheckListItems: [task.checklist[0]]});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(task.checklist.length);
expect(syncedTask.checklist[0].text).to.equal(updateCheckListText);
expect(syncedMemberTask.checklist.length).to.equal(task.checklist.length);
expect(syncedMemberTask.checklist[0].text).to.equal(updateCheckListText);
});
it('removes a checklist item in assigned task to all users when flag is passed with checklist id', async () => {
if (task.type !== 'daily' && task.type !== 'todo') return;
await guild.updateTask(task, {removedCheckListItemId: task.checklist[0].id});
let updatedLeader = await User.findOne({_id: leader._id});
let updatedLeadersTasks = await Tasks.Task.find({_id: { $in: updatedLeader.tasksOrder[`${taskType}s`]}});
let syncedTask = find(updatedLeadersTasks, findLinkedTask);
let updatedMember = await User.findOne({_id: newMember._id});
let updatedMemberTasks = await Tasks.Task.find({_id: { $in: updatedMember.tasksOrder[`${taskType}s`]}});
let syncedMemberTask = find(updatedMemberTasks, findLinkedTask);
expect(syncedTask.checklist.length).to.equal(0);
expect(syncedMemberTask.checklist.length).to.equal(0);
});
}); });
it('removes an assigned task and unlinks assignees', async () => { it('removes an assigned task and unlinks assignees', async () => {

View File

@@ -275,7 +275,8 @@ describe('Challenges Controller', function() {
describe('editTask', function() { describe('editTask', function() {
it('is Tasks.editTask', function() { it('is Tasks.editTask', function() {
inject(function(Tasks) { inject(function(Tasks) {
expect(scope.editTask).to.eql(Tasks.editTask); // @TODO: Currently we override the task function in the challenge ctrl, but we should abstract it again
// expect(scope.editTask).to.eql(Tasks.editTask);
}); });
}); });
}); });

View File

@@ -32,7 +32,8 @@ describe('Tasks Controller', function() {
describe('editTask', function() { describe('editTask', function() {
it('is Tasks.editTask', function() { it('is Tasks.editTask', function() {
inject(function(Tasks) { inject(function(Tasks) {
expect(scope.editTask).to.eql(Tasks.editTask); // @TODO: Currently we override the task function in the challenge ctrl, but we should abstract it again
// expect(scope.editTask).to.eql(Tasks.editTask);
}); });
}); });
}); });

View File

@@ -16,6 +16,8 @@ describe('Tasks Service', function() {
rootScope.charts = {}; rootScope.charts = {};
tasks = Tasks; tasks = Tasks;
}); });
rootScope.openModal = function () {};
}); });
it('calls get user tasks endpoint', function() { it('calls get user tasks endpoint', function() {
@@ -151,7 +153,6 @@ describe('Tasks Service', function() {
}); });
describe('editTask', function() { describe('editTask', function() {
var task; var task;
beforeEach(function(){ beforeEach(function(){

View File

@@ -157,6 +157,16 @@ describe('shared.ops.scoreTask', () => {
expect(ref.afterUser._tmp.quest.progressDelta).to.eql(secondTaskDelta); expect(ref.afterUser._tmp.quest.progressDelta).to.eql(secondTaskDelta);
}); });
it('does not modify stats when task need approval', () => {
todo.group.approval.required = true;
options = { user: ref.afterUser, task: todo, direction: 'up', times: 5, cron: false };
scoreTask(options);
expect(ref.afterUser.stats.hp).to.eql(50);
expect(ref.afterUser.stats.exp).to.equal(ref.beforeUser.stats.exp);
expect(ref.afterUser.stats.gp).to.equal(ref.beforeUser.stats.gp);
});
context('habits', () => { context('habits', () => {
it('up', () => { it('up', () => {
options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false }; options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false };

View File

@@ -13,5 +13,7 @@ chai.use(require('sinon-chai'));
chai.use(require('chai-as-promised')); chai.use(require('chai-as-promised'));
global.expect = chai.expect; global.expect = chai.expect;
global.sinon = require('sinon'); global.sinon = require('sinon');
let sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(global.sinon);
global.sandbox = sinon.sandbox.create(); global.sandbox = sinon.sandbox.create();
global.Promise = Bluebird; global.Promise = Bluebird;

View File

@@ -8,59 +8,67 @@
// array of keywords and their associated color vars // array of keywords and their associated color vars
$stages = (worst $worst) (worse $worse) (bad $bad) (neutral $neutral) (good $good) (better $better) (best $best) $stages = (worst $worst) (worse $worse) (bad $bad) (neutral $neutral) (good $good) (better $better) (best $best)
taskContainerStyles($stage)
background-color: $stage[1]
border: 1px solid shade($stage[1],10%)
.priority-multiplier, .task-attributes, .repeat-days, .repeat-frequency
li
hrpg-button-color-mixin($stage[1])
button
&.active
box-shadow: inset 0 0 0 1px darken($stage[1],40%) !important
background-color: darken($stage[1],5%) !important
&:focus
border: 1px solid darken($stage[1],30%)
outline: 0
.plusminus
.task-checker
label:after
border: 1px solid darken($stage[1], 30%) !important
input[type=checkbox]:checked + label:after
//border: 1px solid darken($stage[1], 50%) !important
box-shadow: inset 0 0 0 1px darken($stage[1],40%) !important
background-color: darken($stage[1],5%) !important
.save-close, .task-checklist-edit li
hrpg-button-color-mixin(darken($stage[1],5%))
button
&:focus
border: 1px solid darken($stage[1],30%)
outline: 0
.task-actions
background-color: darken($stage[1], 30%)
.action-yesno label,
.task-action-btn,
.task-actions a
background-color: darken($stage[1], 30%)
&:hover, &:focus
background-color: darken($stage[1], 40%)
input[type=checkbox].task-input:focus + label, input.habit:focus + a
background-color: darken($stage[1], 40%)
.task-actions a:nth-of-type(2)
border-top: 1px solid darken($stage[1],50%) // If there are two habit buttons (+ -), add a border to separate them
.task-options
background-color: $stage[1]
.option-group:not(.task-checklist)
border-bottom: 1px solid darken($stage[1], 15%)
.option-content
border-color: darken($stage[1], 16.18%) !important
&:hover
border-color: darken($stage[1], 32.8%) !important
&:focus
border-color: darken($stage[1], 61.8%) !important
outline: none;
// for each color stage, generate a named class w/ the appropriate color // for each color stage, generate a named class w/ the appropriate color
for $stage in $stages for $stage in $stages
.task-column:not(.rewards) .task-column:not(.rewards)
.color-{$stage[0]}:not(.completed) .color-{$stage[0]}:not(.completed)
background-color: $stage[1] taskContainerStyles($stage)
border: 1px solid shade($stage[1],10%)
.priority-multiplier, .task-attributes, .repeat-days, .repeat-frequency .task-modal
li &.color-{$stage[0]}:not(.completed)
hrpg-button-color-mixin($stage[1]) taskContainerStyles($stage)
button
&.active
box-shadow: inset 0 0 0 1px darken($stage[1],40%) !important
background-color: darken($stage[1],5%) !important
&:focus
border: 1px solid darken($stage[1],30%)
outline: 0
.plusminus
.task-checker
label:after
border: 1px solid darken($stage[1], 30%) !important
input[type=checkbox]:checked + label:after
//border: 1px solid darken($stage[1], 50%) !important
box-shadow: inset 0 0 0 1px darken($stage[1],40%) !important
background-color: darken($stage[1],5%) !important
.save-close, .task-checklist-edit li
hrpg-button-color-mixin(darken($stage[1],5%))
button
&:focus
border: 1px solid darken($stage[1],30%)
outline: 0
.task-actions
background-color: darken($stage[1], 30%)
.action-yesno label,
.task-action-btn,
.task-actions a
background-color: darken($stage[1], 30%)
&:hover, &:focus
background-color: darken($stage[1], 40%)
input[type=checkbox].task-input:focus + label, input.habit:focus + a
background-color: darken($stage[1], 40%)
.task-actions a:nth-of-type(2)
border-top: 1px solid darken($stage[1],50%) // If there are two habit buttons (+ -), add a border to separate them
.task-options
background-color: $stage[1]
.option-group:not(.task-checklist)
border-bottom: 1px solid darken($stage[1], 15%)
.option-content
border-color: darken($stage[1], 16.18%) !important
&:hover
border-color: darken($stage[1], 32.8%) !important
&:focus
border-color: darken($stage[1], 61.8%) !important
outline: none;
// completed has to be outside the loop to override the color class // completed has to be outside the loop to override the color class
.completed .completed
@@ -366,6 +374,12 @@ for $stage in $stages
text-align: center text-align: center
opacity: 0.75 opacity: 0.75
// Group yesno
.group-yesno
label:hover:after, input[type=checkbox]:checked + label:after
content: "" !important
opacity: 1 !important
// secondary task commands // secondary task commands
// ----------------------- // -----------------------

View File

@@ -139,6 +139,13 @@ window.habitrpg = angular.module('habitrpg',
title: env.t('titlePatrons') title: env.t('titlePatrons')
}) })
.state('options.social.groupPlans', {
url: '/group-plans',
templateUrl: "partials/options.social.groupPlans.html",
controller: 'GroupPlansCtrl',
title: env.t('groupPlansTitle')
})
.state('options.social.guilds', { .state('options.social.guilds', {
url: '/guilds', url: '/guilds',
templateUrl: "partials/options.social.guilds.html", templateUrl: "partials/options.social.guilds.html",
@@ -155,38 +162,55 @@ window.habitrpg = angular.module('habitrpg',
templateUrl: "partials/options.social.guilds.create.html", templateUrl: "partials/options.social.guilds.create.html",
title: env.t('titleGuilds') title: env.t('titleGuilds')
}) })
.state('options.social.guilds.detail', { .state('options.social.guilds.detail', {
url: '/:gid', url: '/:gid',
templateUrl: 'partials/options.social.guilds.detail.html', templateUrl: 'partials/options.social.guilds.detail.html',
title: env.t('titleGuilds'), title: env.t('titleGuilds'),
controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', 'Tasks', controller: ['$scope', 'Groups', 'Chat', '$stateParams', 'Members', 'Challenges', 'Tasks', 'User', '$location',
function($scope, Groups, Chat, $stateParams, Members, Challenges, Tasks) { function($scope, Groups, Chat, $stateParams, Members, Challenges, Tasks, User, $location) {
$scope.groupPanel = 'chat';
$scope.upgrade = false;
// @TODO: Move this to service or single http request
Groups.Group.get($stateParams.gid) Groups.Group.get($stateParams.gid)
.then(function (response) { .then(function (response) {
$scope.obj = $scope.group = response.data.data; $scope.obj = $scope.group = response.data.data;
Chat.markChatSeen($scope.group._id); return Chat.markChatSeen($scope.group._id);
Members.getGroupMembers($scope.group._id)
.then(function (response) {
$scope.group.members = response.data.data;
});
Members.getGroupInvites($scope.group._id)
.then(function (response) {
$scope.group.invites = response.data.data;
});
Challenges.getGroupChallenges($scope.group._id)
.then(function (response) {
$scope.group.challenges = response.data.data;
});
//@TODO: Add this back when group tasks go live
// Tasks.getGroupTasks($scope.group._id);
// .then(function (response) {
// var tasks = response.data.data;
// tasks.forEach(function (element, index, array) {
// if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
// $scope.group[element.type + 's'].push(element);
// })
// });
}) })
.then (function () {
return Members.getGroupMembers($scope.group._id);
})
.then(function (response) {
$scope.group.members = response.data.data;
return Members.getGroupInvites($scope.group._id);
})
.then(function (response) {
$scope.group.invites = response.data.data;
return Challenges.getGroupChallenges($scope.group._id);
})
.then(function (response) {
$scope.group.challenges = response.data.data;
return Tasks.getGroupTasks($scope.group._id);
})
.then(function (response) {
var tasks = response.data.data;
tasks.forEach(function (element, index, array) {
if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
$scope.group[element.type + 's'].unshift(element);
});
$scope.group.approvals = [];
if (User.user._id === $scope.group.leader._id) {
return Tasks.getGroupApprovals($scope.group._id);
}
})
.then(function (response) {
if (response) $scope.group.approvals = response.data.data;
});
}] }]
}) })

View File

@@ -1,21 +1,23 @@
habitrpg.controller('GroupApprovalsCtrl', ['$scope', 'Tasks', habitrpg.controller('GroupApprovalsCtrl', ['$scope', 'Tasks',
function ($scope, Tasks) { function ($scope, Tasks) {
$scope.approvals = []; $scope.approve = function (taskId, userId, username, $index) {
if (!confirm(env.t('confirmTaskApproval', {username: username}))) return;
// Tasks.getGroupApprovals($scope.group._id)
// .then(function (response) {
// $scope.approvals = response.data.data;
// });
$scope.approve = function (taskId, userId, $index) {
if (!confirm(env.t('confirmTaskApproval'))) return;
Tasks.approve(taskId, userId) Tasks.approve(taskId, userId)
.then(function (response) { .then(function (response) {
$scope.approvals.splice($index, 1); $scope.group.approvals.splice($index, 1);
}); });
}; };
$scope.approvalTitle = function (approval) { $scope.approvalTitle = function (approval) {
return env.t('approvalTitle', {text: approval.text, userName: approval.userId.profile.name}); return env.t('approvalTitle', {text: approval.text, userName: approval.userId.profile.name});
}; };
$scope.refreshApprovals = function () {
$scope.loading = true;
Tasks.getGroupApprovals($scope.group._id)
.then(function (response) {
if (response) $scope.group.approvals = response.data.data;
$scope.loading = false;
});
};
}]); }]);

View File

@@ -27,12 +27,17 @@
})); }));
var currentTags = []; var currentTags = [];
_.each(scope.task.group.assignedUsers, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) }) _.each(scope.task.group.assignedUsers, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) });
var allowedTags = [];
_.each(scope.task.group.members, function(userId) { currentTags.push(memberIdToProfileNameMap[userId]) });
var taggle = new Taggle('taggle', { var taggle = new Taggle('taggle', {
tags: currentTags, tags: currentTags,
allowedTags: currentTags, allowedTags: allowedTags,
allowDuplicates: false, allowDuplicates: false,
preserveCase: true,
placeholder: window.env.t('assignFieldPlaceholder'),
onBeforeTagAdd: function(event, tag) { onBeforeTagAdd: function(event, tag) {
return confirm(window.env.t('confirmAddTag', {tag: tag})); return confirm(window.env.t('confirmAddTag', {tag: tag}));
}, },

View File

@@ -1,17 +1,65 @@
habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', function ($scope, Shared, Tasks, User) { habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', function ($scope, Shared, Tasks, User) {
$scope.editTask = Tasks.editTask; function handleGetGroupTasks (response) {
var group = $scope.obj;
var tasks = response.data.data;
if (tasks.length === 0) return;
// @TODO: We need to get the task information from createGroupTasks rather than resyncing
group['habits'] = [];
group['dailys'] = [];
group['todos'] = [];
group['rewards'] = [];
tasks.forEach(function (element, index, array) {
if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
$scope.group[element.type + 's'].unshift(element);
})
$scope.loading = false;
};
$scope.refreshTasks = function () {
$scope.loading = true;
Tasks.getGroupTasks($scope.group._id)
.then(handleGetGroupTasks);
};
/*
* Task Edit functions
*/
$scope.toggleBulk = Tasks.toggleBulk; $scope.toggleBulk = Tasks.toggleBulk;
$scope.cancelTaskEdit = Tasks.cancelTaskEdit; $scope.cancelTaskEdit = Tasks.cancelTaskEdit;
$scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
function addTask (listDef, taskTexts) { function addTask (listDef, taskTexts) {
taskTexts.forEach(function (taskText) { taskTexts.forEach(function (taskText) {
var task = Shared.taskDefaults({text: taskText, type: listDef.type}); var task = Shared.taskDefaults({text: taskText, type: listDef.type});
//If the group has not been created, we bulk add tasks on save //If the group has not been created, we bulk add tasks on save
var group = $scope.obj; var group = $scope.obj;
if (group._id) Tasks.createGroupTasks(group._id, task); if (!group._id) return;
if (!group[task.type + 's']) group[task.type + 's'] = [];
group[task.type + 's'].unshift(task); Tasks.createGroupTasks(group._id, task)
.then(function () {
// Set up default group info on task. @TODO: Move this to Tasks.createGroupTasks
task.group = {
id: group._id,
approval: {required: false, approved: false, requested: false},
assignedUsers: [],
};
if (!group[task.type + 's']) group[task.type + 's'] = [];
group[task.type + 's'].unshift(task);
return Tasks.getGroupTasks($scope.group._id);
})
.then(handleGetGroupTasks);
}); });
}; };
@@ -28,8 +76,21 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
}; };
$scope.saveTask = function(task, stayOpen, isSaveAndClose) { $scope.saveTask = function(task, stayOpen, isSaveAndClose) {
Tasks.saveTask (task, stayOpen, isSaveAndClose); // Check if we have a lingering checklist that the enter button did not trigger on
Tasks.updateTask(task._id, task); var lastIndex = task._edit.checklist.length - 1;
var lastCheckListItem = task._edit.checklist[lastIndex];
if (lastCheckListItem && !lastCheckListItem.id && lastCheckListItem.text) {
Tasks.addChecklistItem(task._id, lastCheckListItem)
.then(function (response) {
task._edit.checklist[lastIndex] = response.data.data.checklist[lastIndex];
task.checklist[lastIndex] = response.data.data.checklist[lastIndex];
Tasks.saveTask(task, stayOpen, isSaveAndClose);
Tasks.updateTask(task._id, task);
});
} else {
Tasks.saveTask (task, stayOpen, isSaveAndClose);
Tasks.updateTask(task._id, task);
}
}; };
$scope.shouldShow = function(task, list, prefs){ $scope.shouldShow = function(task, list, prefs){
@@ -63,9 +124,23 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
*/ */
$scope.addChecklist = Tasks.addChecklist; $scope.addChecklist = Tasks.addChecklist;
$scope.addChecklistItem = Tasks.addChecklistItemToUI; $scope.addChecklistItem = function addChecklistItemToUI(task, $event, $index) {
if (task._edit.checklist[$index].justAdded) return;
task._edit.checklist[$index].justAdded = true;
if (!task._edit.checklist[$index].id) {
Tasks.addChecklistItem (task._id, task._edit.checklist[$index])
.then(function (response) {
task._edit.checklist[$index] = response.data.data.checklist[$index];
})
}
Tasks.addChecklistItemToUI(task, $event, $index);
};
$scope.removeChecklistItem = Tasks.removeChecklistItemFromUI; $scope.removeChecklistItem = function (task, $event, $index, force) {
if (!task._edit.checklist[$index].id) return;
Tasks.removeChecklistItem (task._id, task._edit.checklist[$index].id);
Tasks.removeChecklistItemFromUI(task, $event, $index, force);
};
$scope.swapChecklistItems = Tasks.swapChecklistItems; $scope.swapChecklistItems = Tasks.swapChecklistItems;
@@ -78,4 +153,34 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
//@TODO: Currently the api save of the task is separate, so whenever we need to save the task we need to call the respective api //@TODO: Currently the api save of the task is separate, so whenever we need to save the task we need to call the respective api
Tasks.updateTask(task._id, task); Tasks.updateTask(task._id, task);
}; };
$scope.checkGroupAccess = function (group) {
if (!group || !group.leader) return true;
if (User.user._id !== group.leader._id) return false;
return true;
};
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
if ($scope.group) {
var memberIdToProfileNameMap = _.object(_.map($scope.group.members, function(item) {
return [item.id, item.profile.name]
}));
var claimingUsers = [];
task.group.assignedUsers.forEach(function (userId) {
claimingUsers.push(memberIdToProfileNameMap[userId]);
})
if (claimingUsers.length > 0) content += window.env.t('claimedBy', {claimingUsers: claimingUsers.join(', ')});
}
return content;
};
}]); }]);

View File

@@ -34,7 +34,10 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User',
return User.user.challenges.indexOf(challenge._id) !== -1; return User.user.challenges.indexOf(challenge._id) !== -1;
} }
$scope.editTask = Tasks.editTask; $scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
$scope.cancelTaskEdit = Tasks.cancelTaskEdit; $scope.cancelTaskEdit = Tasks.cancelTaskEdit;
$scope.canEdit = function(task) { $scope.canEdit = function(task) {
@@ -313,6 +316,15 @@ habitrpg.controller("ChallengesCtrl", ['$rootScope','$scope', 'Shared', 'User',
$scope.toggleBulk = Tasks.toggleBulk; $scope.toggleBulk = Tasks.toggleBulk;
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
return content;
};
/* /*
-------------------------- --------------------------
Subscription Subscription

View File

@@ -45,4 +45,8 @@ habitrpg.controller("FiltersCtrl", ['$scope', '$rootScope', 'User', 'Shared',
User.addTag({body:{name: $scope._newTag.name, id: Shared.uuid()}}); User.addTag({body:{name: $scope._newTag.name, id: Shared.uuid()}});
$scope._newTag.name = ''; $scope._newTag.name = '';
}; };
$scope.showChallengeClass = function (tag) {
return tag.challenge || tag.group;
};
}]); }]);

View File

@@ -0,0 +1,43 @@
"use strict";
/*
A controller to manage the Group Plans page
*/
angular.module('habitrpg')
.controller("GroupPlansCtrl", ['$scope', '$window', 'Groups', 'Payments',
function($scope, $window, Groups, Payments) {
$scope.PAGES = {
BENEFITS: 'benefits',
CREATE_GROUP: 'create-group',
UPGRADE_GROUP: 'upgrade-group',
};
$scope.activePage = $scope.PAGES.BENEFITS;
$scope.newGroup = {
type: 'guild',
privacy: 'private',
};
$scope.PAYMENTS = {
AMAZON: 'amazon',
STRIPE: 'stripe',
};
$scope.changePage = function (page) {
$scope.activePage = page;
$window.scrollTo(0, 0);
};
$scope.newGroupIsReady = function () {
return $scope.newGroup.name && $scope.newGroup.description;
};
$scope.createGroup = function () {
$scope.changePage($scope.PAGES.UPGRADE_GROUP);
};
$scope.upgradeGroup = function (paymentType) {
var subscriptionKey = 'group_monthly'; // @TODO: Get from content API?
if (paymentType === $scope.PAYMENTS.STRIPE) Payments.showStripe({subscription: subscriptionKey, coupon: null, groupToCreate: $scope.newGroup});
if (paymentType === $scope.PAYMENTS.AMAZON) Payments.amazonPayments.init({type: 'subscription', subscription: subscriptionKey, coupon: null, groupToCreate: $scope.newGroup});
};
}]);

View File

@@ -34,7 +34,7 @@ habitrpg.controller("GuildsCtrl", ['$scope', 'Groups', 'User', 'Challenges', '$r
Groups.Group.create(group) Groups.Group.create(group)
.then(function (response) { .then(function (response) {
var createdGroup = response.data.data; var createdGroup = response.data.data;
$rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id); $rootScope.hardRedirect('/#/options/groups/guilds/' + createdGroup._id + '?upgrade=true');
}); });
} }
} }

View File

@@ -11,6 +11,7 @@ angular.module('habitrpg')
function selectNotificationValue(mysteryValue, invitationValue, cardValue, unallocatedValue, messageValue, noneValue, groupApprovalRequested, groupApproved) { function selectNotificationValue(mysteryValue, invitationValue, cardValue, unallocatedValue, messageValue, noneValue, groupApprovalRequested, groupApproved) {
var user = $scope.user; var user = $scope.user;
if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) { if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) {
return mysteryValue; return mysteryValue;
} else if ((user.invitations.party && user.invitations.party.id) || (user.invitations.guilds && user.invitations.guilds.length > 0)) { } else if ((user.invitations.party && user.invitations.party.id) || (user.invitations.guilds && user.invitations.guilds.length > 0)) {

View File

@@ -1,8 +1,8 @@
'use strict'; 'use strict';
habitrpg.controller('NotificationCtrl', habitrpg.controller('NotificationCtrl',
['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', 'Achievement', 'Social', ['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', 'Achievement', 'Social', 'Tasks',
function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics, Achievement, Social) { function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics, Achievement, Social, Tasks) {
$rootScope.$watch('user.stats.hp', function (after, before) { $rootScope.$watch('user.stats.hp', function (after, before) {
if (after <= 0){ if (after <= 0){
@@ -87,6 +87,8 @@ habitrpg.controller('NotificationCtrl',
if (!after || after.length === 0) return; if (!after || after.length === 0) return;
var notificationsToRead = []; var notificationsToRead = [];
var scoreTaskNotification;
after.forEach(function (notification) { after.forEach(function (notification) {
if (lastShownNotifications.indexOf(notification.id) !== -1) { if (lastShownNotifications.indexOf(notification.id) !== -1) {
return; return;
@@ -141,6 +143,9 @@ habitrpg.controller('NotificationCtrl',
trasnferGroupNotification(notification); trasnferGroupNotification(notification);
markAsRead = false; markAsRead = false;
break; break;
case 'SCORED_TASK':
scoreTaskNotification = notification;
break;
case 'LOGIN_INCENTIVE': case 'LOGIN_INCENTIVE':
Notification.showLoginIncentive(User.user, notification.data, Social.loadWidgets); Notification.showLoginIncentive(User.user, notification.data, Social.loadWidgets);
break; break;
@@ -159,7 +164,17 @@ habitrpg.controller('NotificationCtrl',
if (markAsRead) notificationsToRead.push(notification.id); if (markAsRead) notificationsToRead.push(notification.id);
}); });
User.readNotifications(notificationsToRead); var userReadNotifsPromise = User.readNotifications(notificationsToRead);
if (userReadNotifsPromise) {
userReadNotifsPromise.then(function () {
if (scoreTaskNotification) {
Notification.markdown(scoreTaskNotification.data.message);
User.score({params:{task: scoreTaskNotification.data.scoreTask, direction: "up"}});
}
});
}
User.user.notifications = []; // reset the notifications User.user.notifications = []; // reset the notifications
} }

View File

@@ -10,6 +10,7 @@ habitrpg.controller("PartyCtrl", ['$rootScope','$scope','Groups','Chat','User','
$scope.type = 'party'; $scope.type = 'party';
$scope.text = window.env.t('party'); $scope.text = window.env.t('party');
$scope.group = {loadingParty: true}; $scope.group = {loadingParty: true};
$scope.groupPanel = 'chat';
$scope.inviteOrStartParty = Groups.inviteOrStartParty; $scope.inviteOrStartParty = Groups.inviteOrStartParty;
$scope.loadWidgets = Social.loadWidgets; $scope.loadWidgets = Social.loadWidgets;

View File

@@ -51,11 +51,17 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
$scope.toggleBulk = Tasks.toggleBulk; $scope.toggleBulk = Tasks.toggleBulk;
$scope.editTask = Tasks.editTask; $scope.editTask = function (task, user, taskStatus) {
Tasks.editTask(task, user, taskStatus, $scope);
};
$scope.canEdit = function(task) { $scope.canEdit = function(task) {
// can't edit challenge tasks // can't edit challenge tasks
return !task.challenge.id; return !task.challenge.id && (!task.group || !task.group.id);
}
$scope.checkGroupAccess = function (group) {
return true;
} }
$scope.doubleClickTask = function (obj, task) { $scope.doubleClickTask = function (obj, task) {
@@ -225,9 +231,10 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
------------------------ ------------------------
*/ */
$scope.shouldShow = function(task, list, prefs){ $scope.shouldShow = function(task, list, prefs) {
if (task._editing) // never hide a task while being edited if (task._editing) // never hide a task while being edited
return true; return true;
var shouldDo = task.type == 'daily' ? habitrpgShared.shouldDo(new Date, task, prefs) : true; var shouldDo = task.type == 'daily' ? habitrpgShared.shouldDo(new Date, task, prefs) : true;
switch (list.view) { switch (list.view) {
case "yellowred": // Habits case "yellowred": // Habits
@@ -324,4 +331,13 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
return notes; return notes;
}; };
/*
* Task Details
*/
$scope.taskPopover = function (task) {
if (task.popoverOpen) return '';
var content = task.notes;
return content;
};
}]); }]);

View File

@@ -37,12 +37,23 @@ function($rootScope, User, $http, Content) {
panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'), panelLabel: sub ? window.env.t('subscribe') : window.env.t('checkout'),
token: function(res) { token: function(res) {
var url = '/stripe/checkout?a=a'; // just so I can concat &x=x below var 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=' + Payments.encodeGift(data.uuid, data.gift); if (data.gift) url += '&gift=' + Payments.encodeGift(data.uuid, data.gift);
if (data.subscription) url += '&sub='+sub.key; if (data.subscription) url += '&sub='+sub.key;
if (data.coupon) url += '&coupon='+data.coupon; if (data.coupon) url += '&coupon='+data.coupon;
if (data.groupId) url += '&groupId=' + data.groupId; if (data.groupId) url += '&groupId=' + data.groupId;
$http.post(url, res).success(function() { $http.post(url, res).success(function(response) {
window.location.reload(true); if (response && response.data && response.data._id) {
$rootScope.hardRedirect('/#/options/groups/guilds/' + response.data._id);
} else {
window.location.reload(true);
}
}).error(function(res) { }).error(function(res) {
alert(res.message); alert(res.message);
}); });
@@ -116,6 +127,10 @@ function($rootScope, User, $http, Content) {
Payments.amazonPayments.groupId = data.groupId; Payments.amazonPayments.groupId = data.groupId;
} }
if (data.groupToCreate) {
Payments.amazonPayments.groupToCreate = data.groupToCreate;
}
Payments.amazonPayments.gift = data.gift; Payments.amazonPayments.gift = data.gift;
Payments.amazonPayments.type = data.type; Payments.amazonPayments.type = data.type;
@@ -255,14 +270,24 @@ function($rootScope, User, $http, Content) {
} else if(Payments.amazonPayments.type === 'subscription') { } else if(Payments.amazonPayments.type === 'subscription') {
var url = '/amazon/subscribe'; var url = '/amazon/subscribe';
if (Payments.amazonPayments.groupToCreate) {
url = '/api/v3/groups/create-plan';
}
$http.post(url, { $http.post(url, {
billingAgreementId: Payments.amazonPayments.billingAgreementId, billingAgreementId: Payments.amazonPayments.billingAgreementId,
subscription: Payments.amazonPayments.subscription, subscription: Payments.amazonPayments.subscription,
coupon: Payments.amazonPayments.coupon, coupon: Payments.amazonPayments.coupon,
groupId: Payments.amazonPayments.groupId, groupId: Payments.amazonPayments.groupId,
}).success(function(){ groupToCreate: Payments.amazonPayments.groupToCreate,
paymentType: 'Amazon',
}).success(function(response) {
Payments.amazonPayments.reset(); Payments.amazonPayments.reset();
window.location.reload(true); if (response && response.data && response.data._id) {
$rootScope.hardRedirect('/#/options/groups/guilds/' + response.data._id);
} else {
window.location.reload(true);
}
}).error(function(res){ }).error(function(res){
alert(res.message); alert(res.message);
Payments.amazonPayments.reset(); Payments.amazonPayments.reset();

View File

@@ -245,12 +245,31 @@ angular.module('habitrpg')
}); });
}; };
function editTask(task, user) { function editTask(task, user, taskStatus, scopeInc) {
task._editing = true; var modalScope = $rootScope.$new();
task._tags = !user.preferences.tagsCollapsed; modalScope.task = task;
task._advanced = !user.preferences.advancedCollapsed; modalScope.task._editing = true;
task._edit = angular.copy(task); modalScope.task._tags = !user.preferences.tagsCollapsed;
modalScope.task._advanced = !user.preferences.advancedCollapsed;
modalScope.task._edit = angular.copy(task);
if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false; if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false;
modalScope.taskStatus = taskStatus;
if (scopeInc) {
modalScope.saveTask = scopeInc.saveTask;
modalScope.addChecklist = scopeInc.addChecklist;
modalScope.addChecklistItem = scopeInc.addChecklistItem;
modalScope.removeChecklistItem = scopeInc.removeChecklistItem;
modalScope.swapChecklistItems = scopeInc.swapChecklistItems;
modalScope.navigateChecklist = scopeInc.navigateChecklist;
modalScope.checklistCompletion = scopeInc.checklistCompletion;
modalScope.canEdit = scopeInc.canEdit;
modalScope.updateTaskTags = scopeInc.updateTaskTags;
modalScope.obj = scopeInc.obj;
}
modalScope.cancelTaskEdit = cancelTaskEdit;
$rootScope.openModal('task-edit', {scope: modalScope, backdrop: 'static'});
} }
function cancelTaskEdit(task) { function cancelTaskEdit(task) {
@@ -289,7 +308,7 @@ angular.module('habitrpg')
function focusChecklist(task, index) { function focusChecklist(task, index) {
window.setTimeout(function(){ window.setTimeout(function(){
$('#task-'+task._id+' .checklist-form input[type="text"]')[index].focus(); $('#task-' + task._id + ' .checklist-form input[type="text"]')[index].focus();
}); });
} }

View File

@@ -329,7 +329,7 @@ angular.module('habitrpg')
}, },
readNotifications: function (notificationIds) { readNotifications: function (notificationIds) {
UserNotifications.readNotifications(notificationIds); return UserNotifications.readNotifications(notificationIds);
}, },
addTag: function(data) { addTag: function(data) {

View File

@@ -109,6 +109,7 @@
"js/controllers/tavernCtrl.js", "js/controllers/tavernCtrl.js",
"js/controllers/tasksCtrl.js", "js/controllers/tasksCtrl.js",
"js/controllers/userCtrl.js", "js/controllers/userCtrl.js",
"js/controllers/groupPlansCtrl.js",
"js/components/groupTasks/groupTasksController.js", "js/components/groupTasks/groupTasksController.js",
"js/components/groupTasks/groupTasksDirective.js", "js/components/groupTasks/groupTasksDirective.js",

View File

@@ -53,6 +53,7 @@
"help": "Help", "help": "Help",
"user": "User", "user": "User",
"market": "Market", "market": "Market",
"groupPlansTitle": "Group Plans",
"subscriberItem": "Mystery Item", "subscriberItem": "Mystery Item",
"newSubscriberItem": "New Mystery Item", "newSubscriberItem": "New Mystery Item",
"subscriberItemText": "Each month, subscribers will receive a mystery item. This is usually released about one week before the end of the month. See the wiki's 'Mystery Item' page for more information.", "subscriberItemText": "Each month, subscribers will receive a mystery item. This is usually released about one week before the end of the month. See the wiki's 'Mystery Item' page for more information.",
@@ -194,5 +195,7 @@
"you": "(you)", "you": "(you)",
"enableDesktopNotifications": "Enable Desktop Notifications", "enableDesktopNotifications": "Enable Desktop Notifications",
"online": "online", "online": "online",
"onlineCount": "<%= count %> online" "onlineCount": "<%= count %> online",
"loading": "Loading...",
"userIdRequired": "User ID is required"
} }

View File

@@ -113,7 +113,9 @@
"toUserIDRequired": "A User ID is required", "toUserIDRequired": "A User ID is required",
"gemAmountRequired": "A number of gems is required", "gemAmountRequired": "A number of gems is required",
"notAuthorizedToSendMessageToThisUser": "Can't send message to this user.", "notAuthorizedToSendMessageToThisUser": "Can't send message to this user.",
"privateMessageGiftGemsMessage": "Hello <%= receiverName %>, <%= senderName %> has sent you <%= gemAmount %> gems!", "privateMessageGiftIntro": "Hello <%= receiverName %>, <%= senderName %> has sent you ",
"privateMessageGiftGemsMessage": "<%= gemAmount %> gems!",
"privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ",
"cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.",
"badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.", "badAmountOfGemsToSend": "Amount must be within 1 and your current number of gems.",
"abuseFlag": "Report violation of Community Guidelines", "abuseFlag": "Report violation of Community Guidelines",
@@ -170,8 +172,8 @@
"requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.", "requestAcceptGuidelines": "If you would like to post messages in the Tavern or any party or guild chat, please first read our <%= linkStart %>Community Guidelines<%= linkEnd %> and then click the button below to indicate that you accept them.",
"partyUpName": "Party Up", "partyUpName": "Party Up",
"partyOnName": "Party On", "partyOnName": "Party On",
"partyUpText": "Joined a Party with another person! Have fun battling monsters and supporting each other.", "partyUpAchievement": "Joined a Party with another person! Have fun battling monsters and supporting each other.",
"partyOnText": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!", "partyOnAchievement": "Joined a Party with at least four people! Enjoy your increased accountability as you unite with your friends to vanquish your foes!",
"largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.", "largeGroupNote": "Note: This Guild is now too large to support notifications! Be sure to check back every day to see new messages.",
"groupIdRequired": "\"groupId\" must be a valid UUID", "groupIdRequired": "\"groupId\" must be a valid UUID",
"groupNotFound": "Group not found or you don't have access.", "groupNotFound": "Group not found or you don't have access.",
@@ -210,15 +212,46 @@
"to": "To:", "to": "To:",
"from": "From:", "from": "From:",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.", "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"confirmAddTag": "Do you really want to add \"<%= tag %>\"?", "confirmAddTag": "Do you want to assign this task to \"<%= tag %>\"?",
"confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?", "confirmRemoveTag": "Do you really want to remove \"<%= tag %>\"?",
"groupHomeTitle": "Home",
"assignTask": "Assign Task", "assignTask": "Assign Task",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.", "desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"claim": "Claim", "claim": "Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription", "onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"yourTaskHasBeenApproved": "Your task has been approved", "yourTaskHasBeenApproved": "Your task \"<%= taskText %>\" has been approved",
"userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>", "userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>",
"confirmTaskApproval": "Are you sure you want to approve this task?",
"approve": "Approve", "approve": "Approve",
"approvalTitle": "<%= text %> for user: <%= userName %>" "approvalTitle": "<%= text %> for user: <%= userName %>",
"confirmTaskApproval": "Do you want to reward <%= username %> for completing this task?",
"groupSubscriptionPrice": "$9 every month + $3 a month for every additional group member",
"groupBenefitsTitle": "How a group plan can help you",
"groupBenefitsDescription": "Weve just launched the beta version of our group plans! Upgrading to a group plan unlocks some unique features to optimize the social side of Habitica.",
"groupBenefitOneTitle": "Create a shared task list",
"groupBenefitOneDescription": "Set up a shared task list for the group that everyone can easily view and edit.",
"groupBenefitTwoTitle": "Assign tasks to group members",
"groupBenefitTwoDescription": "Want a coworker to answer a critical email? Need your roommate to pick up the groceries? Just assign them the tasks you create, and theyll automatically appear in that persons task dashboard.",
"groupBenefitThreeTitle": "Claim a task that you are working on",
"groupBenefitThreeDescription": "Stake your claim on any group task with a simple click. Make it clear what everybody is working on!",
"groupBenefitFourTitle": "Mark tasks that require special approval",
"groupBenefitFourDescription": "Need to verify that a task really did get done before that user gets their rewards? Just adjust the approval settings for added control.",
"groupBenefitFiveTitle": "Chat privately with your group",
"groupBenefitFiveDescription": "Stay in the loop about important decisions in our easy-to-use chatroom!",
"createAGroup": "Create A Group",
"assignFieldPlaceholder": "Type a group member's profile name",
"cannotDeleteActiveGroup": "You cannot remove a group with an active subscription",
"groupTasksTitle": "Group Tasks List",
"approvalsTitle": "Tasks Awaiting Approval",
"upgradeTitle": "Upgrade",
"blankApprovalsDescription": "When your group completes tasks that need your approval, theyll appear here! Adjust approval requirement settings under task editing.",
"userIsClamingTask": "<%= username %> has claimed \"<%= task %>\"",
"approvalRequested": "Approval Requested",
"refreshApprovals": "Refresh Approvals",
"refreshGroupTasks": "Refresh Group Tasks",
"claimedBy": "\n\nClaimed by: <%= claimingUsers %>",
"cantDeleteAssignedGroupTasks": "Can't delete group tasks that are assigned to you.",
"confirmGuildPlanCreation": "Create this group?"
} }

View File

@@ -22,6 +22,7 @@ import gear from './gear';
import appearances from './appearance'; import appearances from './appearance';
import backgrounds from './appearance/backgrounds.js' import backgrounds from './appearance/backgrounds.js'
import spells from './spells'; import spells from './spells';
import subscriptionBlocks from './subscriptionBlocks';
import faq from './faq'; import faq from './faq';
import timeTravelers from './time-travelers'; import timeTravelers from './time-travelers';
@@ -33,6 +34,7 @@ api.itemList = ITEM_LIST;
api.gear = gear; api.gear = gear;
api.spells = spells; api.spells = spells;
api.subscriptionBlocks = subscriptionBlocks;
api.mystery = timeTravelers.mystery; api.mystery = timeTravelers.mystery;
api.timeTravelerStore = timeTravelers.timeTravelerStore; api.timeTravelerStore = timeTravelers.timeTravelerStore;
@@ -2808,35 +2810,6 @@ api.appearances = appearances;
api.backgrounds = backgrounds; api.backgrounds = backgrounds;
api.subscriptionBlocks = {
basic_earned: {
months: 1,
price: 5
},
basic_3mo: {
months: 3,
price: 15
},
basic_6mo: {
months: 6,
price: 30
},
google_6mo: {
months: 6,
price: 24,
discount: true,
original: 30
},
basic_12mo: {
months: 12,
price: 48
},
};
_.each(api.subscriptionBlocks, function(b, k) {
return b.key = k;
});
api.userDefaults = { api.userDefaults = {
habits: [ habits: [
{ {

View File

@@ -0,0 +1,39 @@
/* eslint-disable camelcase */
import _ from 'lodash';
let subscriptionBlocks = {
basic_earned: {
months: 1,
price: 5,
},
basic_3mo: {
months: 3,
price: 15,
},
basic_6mo: {
months: 6,
price: 30,
},
google_6mo: {
months: 6,
price: 24,
discount: true,
original: 30,
},
basic_12mo: {
months: 12,
price: 48,
},
group_monthly: {
type: 'group',
months: 1,
price: 9,
quantity: 3, // Default quantity for Stripe - The same as having 3 user subscriptions
},
};
_.each(subscriptionBlocks, function createKeys (b, k) {
return b.key = k;
});
module.exports = subscriptionBlocks;

View File

@@ -179,6 +179,8 @@ module.exports = function scoreTask (options = {}, req = {}) {
exp: user.stats.exp, exp: user.stats.exp,
}; };
if (task.group && task.group.approval && task.group.approval.required && !task.group.approval.approved) return;
// This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying // This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
// the API consumer, then cleared afterwards // the API consumer, then cleared afterwards
user._tmp = {}; user._tmp = {};

View File

@@ -21,6 +21,9 @@ import { encrypt } from '../../libs/encryption';
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; import { sendNotification as sendPushNotification } from '../../libs/pushNotifications';
import pusher from '../../libs/pusher'; import pusher from '../../libs/pusher';
import common from '../../../common'; import common from '../../../common';
import payments from '../../libs/payments';
import shared from '../../../common';
/** /**
* @apiDefine GroupBodyInvalid * @apiDefine GroupBodyInvalid
@@ -106,6 +109,95 @@ api.createGroup = {
}, },
}; };
/**
* @api {post} /api/v3/groups/create-plan Create a Group and then redirect to the correct payment
* @apiName CreateGroupPlan
* @apiGroup Group
*
* @apiSuccess {Object} data The created group
*/
api.createGroupPlan = {
method: 'POST',
url: '/groups/create-plan',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let group = new Group(Group.sanitize(req.body.groupToCreate));
req.checkBody('paymentType', res.t('paymentTypeRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
// @TODO: Change message
if (group.privacy !== 'private') throw new NotAuthorized(res.t('partyMustbePrivate'));
group.leader = user._id;
user.guilds.push(group._id);
let results = await Bluebird.all([user.save(), group.save()]);
let savedGroup = results[1];
// Analytics
let analyticsObject = {
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
};
res.analytics.track('join group', analyticsObject);
if (req.body.paymentType === 'Stripe') {
let token = req.body.id;
let gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
let sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
let groupId = savedGroup._id;
let email = req.body.email;
let headers = req.headers;
let coupon = req.query.coupon;
await payments.payWithStripe([
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
]);
} else if (req.body.paymentType === 'Amazon') {
let billingAgreementId = req.body.billingAgreementId;
let sub = req.body.subscription ? shared.content.subscriptionBlocks[req.body.subscription] : false;
let coupon = req.body.coupon;
let groupId = savedGroup._id;
let headers = req.headers;
await payments.subscribeWithAmazon([
billingAgreementId,
sub,
coupon,
user,
groupId,
headers,
]);
}
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]); // doc.populate doesn't return a promise
let response = savedGroup.toJSON();
// the leader is the authenticated user
response.leader = {
_id: user._id,
profile: {name: user.profile.name},
};
res.respond(201, response); // do not remove chat flags data as we've just created the group
},
};
/** /**
* @api {get} /api/v3/groups Get groups for a user * @api {get} /api/v3/groups Get groups for a user
* @apiName GetGroups * @apiName GetGroups
@@ -303,6 +395,8 @@ api.joinGroup = {
group.memberCount += 1; group.memberCount += 1;
if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
let promises = [group.save(), user.save()]; let promises = [group.save(), user.save()];
if (inviter) { if (inviter) {
@@ -459,6 +553,8 @@ api.leaveGroup = {
await group.leave(user, req.query.keep); await group.leave(user, req.query.keep);
if (group.purchased.plan && group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
_removeMessagesFromMember(user, group._id); _removeMessagesFromMember(user, group._id);
await user.save(); await user.save();
@@ -535,6 +631,7 @@ api.removeGroupMember = {
if (isInGroup) { if (isInGroup) {
group.memberCount -= 1; group.memberCount -= 1;
if (group.purchased.plan.customerId) await payments.updateStripeGroupPlan(group);
if (group.quest && group.quest.leader === member._id) { if (group.quest && group.quest.leader === member._id) {
group.quest.key = undefined; group.quest.key = undefined;

View File

@@ -245,7 +245,7 @@ api.updateTask = {
} else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one } else if (task.userId !== user._id) { // If the task is owned by a user make it's the current one
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
} }
let oldCheckList = task.checklist;
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
@@ -254,6 +254,8 @@ api.updateTask = {
if (!challenge && task.userId && task.challenge && task.challenge.id) { if (!challenge && task.userId && task.challenge && task.challenge.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj); sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else if (!group && task.userId && task.group && task.group.id) {
sanitizedObj = Tasks.Task.sanitizeUserChallengeTask(updatedTaskObj);
} else { } else {
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj); sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
} }
@@ -270,7 +272,15 @@ api.updateTask = {
let savedTask = await task.save(); let savedTask = await task.save();
if (group && task.group.id && task.group.assignedUsers.length > 0) { if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask); let updateCheckListItems = _.remove(sanitizedObj.checklist, function getCheckListsToUpdate (checklist) {
let indexOld = _.findIndex(oldCheckList, function findIndex (check) {
return check.id === checklist.id;
});
if (indexOld !== -1) return checklist.text !== oldCheckList[indexOld].text;
return false; // Only return changes. Adding and remove are handled differently
});
await group.updateTask(savedTask, {updateCheckListItems});
} }
res.respond(200, savedTask); res.respond(200, savedTask);
@@ -508,13 +518,16 @@ api.addChecklistItem = {
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
task.checklist.push(Tasks.Task.sanitizeChecklist(req.body)); let newCheckListItem = Tasks.Task.sanitizeChecklist(req.body);
task.checklist.push(newCheckListItem);
let savedTask = await task.save(); let savedTask = await task.save();
newCheckListItem.id = savedTask.checklist[savedTask.checklist.length - 1].id;
res.respond(200, savedTask); res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask); if (challenge) challenge.updateTask(savedTask);
if (group && task.group.id && task.group.assignedUsers.length > 0) { if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask); await group.updateTask(savedTask, {newCheckListItem});
} }
}, },
}; };
@@ -676,7 +689,7 @@ api.removeChecklistItem = {
res.respond(200, savedTask); res.respond(200, savedTask);
if (challenge) challenge.updateTask(savedTask); if (challenge) challenge.updateTask(savedTask);
if (group && task.group.id && task.group.assignedUsers.length > 0) { if (group && task.group.id && task.group.assignedUsers.length > 0) {
await group.updateTask(savedTask); await group.updateTask(savedTask, {removedCheckListItemId: req.params.itemId});
} }
}, },
}; };
@@ -941,6 +954,8 @@ api.deleteTask = {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
} else if (task.userId && task.challenge.id && !task.challenge.broken) { } else if (task.userId && task.challenge.id && !task.challenge.broken) {
throw new NotAuthorized(res.t('cantDeleteChallengeTasks')); throw new NotAuthorized(res.t('cantDeleteChallengeTasks'));
} else if (task.group.id && task.group.assignedUsers.indexOf(user._id) !== -1) {
throw new NotAuthorized(res.t('cantDeleteAssignedGroupTasks'));
} }
if (task.type !== 'todo' || !task.completed) { if (task.type !== 'todo' || !task.completed) {

View File

@@ -1,5 +1,4 @@
import { authWithHeaders } from '../../../middlewares/auth'; import { authWithHeaders } from '../../../middlewares/auth';
import ensureDevelpmentMode from '../../../middlewares/ensureDevelpmentMode';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
import * as Tasks from '../../../models/task'; import * as Tasks from '../../../models/task';
import { model as Group } from '../../../models/group'; import { model as Group } from '../../../models/group';
@@ -22,7 +21,6 @@ let api = {};
* @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks. * @apiDescription Can be passed an object to create a single task or an array of objects to create multiple tasks.
* @apiName CreateGroupTasks * @apiName CreateGroupTasks
* @apiGroup Task * @apiGroup Task
* @apiIgnore
* *
* @apiParam {UUID} groupId The id of the group the new task(s) will belong to * @apiParam {UUID} groupId The id of the group the new task(s) will belong to
* *
@@ -31,7 +29,7 @@ let api = {};
api.createGroupTasks = { api.createGroupTasks = {
method: 'POST', method: 'POST',
url: '/tasks/group/:groupId', url: '/tasks/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
@@ -55,7 +53,6 @@ api.createGroupTasks = {
* @api {get} /api/v3/tasks/group/:groupId Get a group's tasks * @api {get} /api/v3/tasks/group/:groupId Get a group's tasks
* @apiName GetGroupTasks * @apiName GetGroupTasks
* @apiGroup Task * @apiGroup Task
* @apiIgnore
* *
* @apiParam {UUID} groupId The id of the group from which to retrieve the tasks * @apiParam {UUID} groupId The id of the group from which to retrieve the tasks
* @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks * @apiParam {string="habits","dailys","todos","rewards"} type Optional query parameter to return just a type of tasks
@@ -65,7 +62,7 @@ api.createGroupTasks = {
api.getGroupTasks = { api.getGroupTasks = {
method: 'GET', method: 'GET',
url: '/tasks/group/:groupId', url: '/tasks/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types); req.checkQuery('type', res.t('invalidTaskType')).optional().isIn(types);
@@ -97,7 +94,7 @@ api.getGroupTasks = {
api.assignTask = { api.assignTask = {
method: 'POST', method: 'POST',
url: '/tasks/:taskId/assign/:assignedUserId', url: '/tasks/:taskId/assign/:assignedUserId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID(); req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -120,12 +117,22 @@ api.assignTask = {
throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned')); throw new NotAuthorized(res.t('onlyGroupTasksCanBeAssigned'));
} }
let group = await Group.getGroup({user, groupId: task.group.id, fields: requiredGroupFields}); let groupFields = `${requiredGroupFields} chat`;
let group = await Group.getGroup({user, groupId: task.group.id, fields: groupFields});
if (!group) throw new NotFound(res.t('groupNotFound')); if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.leader !== user._id && user._id !== assignedUserId) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks')); if (group.leader !== user._id && user._id !== assignedUserId) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
await group.syncTask(task, assignedUser); // User is claiming the task
if (user._id === assignedUserId) {
let message = res.t('userIsClamingTask', {username: user.profile.name, task: task.text});
group.sendChat(message, user);
}
let promises = [];
promises.push(group.syncTask(task, assignedUser));
promises.push(group.save());
await Bluebird.all(promises);
res.respond(200, task); res.respond(200, task);
}, },
@@ -145,7 +152,7 @@ api.assignTask = {
api.unassignTask = { api.unassignTask = {
method: 'POST', method: 'POST',
url: '/tasks/:taskId/unassign/:assignedUserId', url: '/tasks/:taskId/unassign/:assignedUserId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID(); req.checkParams('assignedUserId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -194,7 +201,7 @@ api.unassignTask = {
api.approveTask = { api.approveTask = {
method: 'POST', method: 'POST',
url: '/tasks/:taskId/approve/:userId', url: '/tasks/:taskId/approve/:userId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID(); req.checkParams('userId', res.t('userIdRequired')).notEmpty().isUUID();
@@ -226,10 +233,15 @@ api.approveTask = {
task.group.approval.approved = true; task.group.approval.approved = true;
assignedUser.addNotification('GROUP_TASK_APPROVED', { assignedUser.addNotification('GROUP_TASK_APPROVED', {
message: res.t('yourTaskHasBeenApproved'), message: res.t('yourTaskHasBeenApproved', {taskText: task.text}),
groupId: group._id, groupId: group._id,
}); });
assignedUser.addNotification('SCORED_TASK', {
message: res.t('yourTaskHasBeenApproved', {taskText: task.text}),
scoreTask: task,
});
await Bluebird.all([assignedUser.save(), task.save()]); await Bluebird.all([assignedUser.save(), task.save()]);
res.respond(200, task); res.respond(200, task);
@@ -241,7 +253,6 @@ api.approveTask = {
* @apiVersion 3.0.0 * @apiVersion 3.0.0
* @apiName GetGroupApprovals * @apiName GetGroupApprovals
* @apiGroup Task * @apiGroup Task
* @apiIgnore
* *
* @apiParam {UUID} groupId The id of the group from which to retrieve the approvals * @apiParam {UUID} groupId The id of the group from which to retrieve the approvals
* *
@@ -250,7 +261,7 @@ api.approveTask = {
api.getGroupApprovals = { api.getGroupApprovals = {
method: 'GET', method: 'GET',
url: '/approvals/group/:groupId', url: '/approvals/group/:groupId',
middlewares: [ensureDevelpmentMode, authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID(); req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();

View File

@@ -91,6 +91,8 @@ api.checkout = {
let orderReferenceId = req.body.orderReferenceId; let orderReferenceId = req.body.orderReferenceId;
let amount = 5; let amount = 5;
// @TODO: Make thise use payment.subscribeWithAmazon
if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId'); if (!orderReferenceId) throw new BadRequest('Missing req.body.orderReferenceId');
if (gift) { if (gift) {

View File

@@ -49,6 +49,9 @@ api.checkout = {
let groupId = req.query.groupId; let groupId = req.query.groupId;
let coupon; let coupon;
let response; let response;
let subscriptionId;
// @TODO: Update this to use payments.payWithStripe
if (!token) throw new BadRequest('Missing req.body.id'); if (!token) throw new BadRequest('Missing req.body.id');
@@ -59,12 +62,20 @@ api.checkout = {
if (!coupon) throw new BadRequest(res.t('invalidCoupon')); if (!coupon) throw new BadRequest(res.t('invalidCoupon'));
} }
response = await stripe.customers.create({ let customerObject = {
email: req.body.email, email: req.body.email,
metadata: { uuid: user._id }, metadata: { uuid: user._id },
card: token, card: token,
plan: sub.key, plan: sub.key,
}); };
if (groupId) {
customerObject.quantity = sub.quantity;
}
response = await stripe.customers.create(customerObject);
if (groupId) subscriptionId = response.subscriptions.data[0].id;
} else { } else {
let amount = 500; // $5 let amount = 500; // $5
@@ -91,6 +102,7 @@ api.checkout = {
sub, sub,
headers: req.headers, headers: req.headers,
groupId, groupId,
subscriptionId,
}); });
} else { } else {
let method = 'buyGems'; let method = 'buyGems';
@@ -149,7 +161,9 @@ api.subscribeEdit = {
throw new NotFound(res.t('groupNotFound')); throw new NotFound(res.t('groupNotFound'));
} }
if (!group.leader === user._id) { let allowedManagers = [group.leader, group.purchased.plan.owner];
if (allowedManagers.indexOf(user._id) === -1) {
throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription')); throw new NotAuthorized(res.t('onlyGroupLeaderCanManageSubscription'));
} }
customerId = group.purchased.plan.customerId; customerId = group.purchased.plan.customerId;

View File

@@ -11,11 +11,23 @@ import {
model as Group, model as Group,
basicFields as basicGroupFields, basicFields as basicGroupFields,
} from '../models/group'; } from '../models/group';
import { model as Coupon } from '../models/coupon';
import { model as User } from '../models/user';
import { import {
NotAuthorized, NotAuthorized,
NotFound, NotFound,
} from './errors'; } from './errors';
import slack from './slack'; import slack from './slack';
import nconf from 'nconf';
import stripeModule from 'stripe';
import amzLib from './amazonPayments';
import {
BadRequest,
} from './errors';
import cc from 'coupon-code';
const stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
let api = {}; let api = {};
@@ -49,6 +61,7 @@ api.createSubscription = async function createSubscription (data) {
let groupId; let groupId;
let itemPurchased = 'Subscription'; let itemPurchased = 'Subscription';
let purchaseType = 'subscribe'; let purchaseType = 'subscribe';
let emailType = 'subscription-begins';
// If we are buying a group subscription // If we are buying a group subscription
if (data.groupId) { if (data.groupId) {
@@ -66,7 +79,9 @@ api.createSubscription = async function createSubscription (data) {
recipient = group; recipient = group;
itemPurchased = 'Group-Subscription'; itemPurchased = 'Group-Subscription';
purchaseType = 'group-subscribe'; purchaseType = 'group-subscribe';
emailType = 'group-subscription-begins';
groupId = group._id; groupId = group._id;
recipient.purchased.plan.quantity = data.sub.quantity;
} }
plan = recipient.purchased.plan; plan = recipient.purchased.plan;
@@ -98,11 +113,16 @@ api.createSubscription = async function createSubscription (data) {
// Specify a lastBillingDate just for Amazon Payments // Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts // Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? today : undefined, lastBillingDate: data.paymentMethod === 'Amazon Payments' ? today : undefined,
owner: data.user._id,
}).defaults({ // allow non-override if a plan was previously used }).defaults({ // allow non-override if a plan was previously used
gemsBought: 0, gemsBought: 0,
dateCreated: today, dateCreated: today,
mysteryItems: [], mysteryItems: [],
}).value(); }).value();
if (data.subscriptionId) {
plan.subscriptionId = data.subscriptionId;
}
} }
// Block sub perks // Block sub perks
@@ -119,7 +139,7 @@ api.createSubscription = async function createSubscription (data) {
} }
if (!data.gift) { if (!data.gift) {
txnEmail(data.user, 'subscription-begins'); txnEmail(data.user, emailType);
} }
analytics.trackPurchase({ analytics.trackPurchase({
@@ -208,12 +228,29 @@ api.createSubscription = async function createSubscription (data) {
}); });
}; };
api.updateStripeGroupPlan = async function updateStripeGroupPlan (group, stripeInc) {
if (group.purchased.plan.paymentMethod !== 'Stripe') return;
let stripeApi = stripeInc || stripe;
let plan = shared.content.subscriptionBlocks.group_monthly;
await stripeApi.subscriptions.update(
group.purchased.plan.subscriptionId,
{
plan: plan.key,
quantity: group.memberCount + plan.quantity - 1,
}
);
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
};
// Sets their subscription to be cancelled later // Sets their subscription to be cancelled later
api.cancelSubscription = async function cancelSubscription (data) { api.cancelSubscription = async function cancelSubscription (data) {
let plan; let plan;
let group; let group;
let cancelType = 'unsubscribe'; let cancelType = 'unsubscribe';
let groupId; let groupId;
let emailType = 'cancel-subscription';
// If we are buying a group subscription // If we are buying a group subscription
if (data.groupId) { if (data.groupId) {
@@ -224,10 +261,13 @@ api.cancelSubscription = async function cancelSubscription (data) {
throw new NotFound(shared.i18n.t('groupNotFound')); throw new NotFound(shared.i18n.t('groupNotFound'));
} }
if (!group.leader === data.user._id) { let allowedManagers = [group.leader, group.purchased.plan.owner];
if (allowedManagers.indexOf(data.user._id) === -1) {
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription')); throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription'));
} }
plan = group.purchased.plan; plan = group.purchased.plan;
emailType = 'group-cancel-subscription';
} else { } else {
plan = data.user.purchased.plan; plan = data.user.purchased.plan;
} }
@@ -252,7 +292,7 @@ api.cancelSubscription = async function cancelSubscription (data) {
await data.user.save(); await data.user.save();
} }
txnEmail(data.user, 'cancel-subscription'); txnEmail(data.user, emailType);
if (group) { if (group) {
cancelType = 'group-unsubscribe'; cancelType = 'group-unsubscribe';
@@ -343,4 +383,180 @@ api.buyGems = async function buyGems (data) {
await data.user.save(); await data.user.save();
}; };
/**
* Allows for purchasing a user subscription, group subscription or gems with Stripe
*
* @param options
* @param options.token The stripe token generated on the front end
* @param options.user The user object who is purchasing
* @param options.gift The gift details if any
* @param options.sub The subscription data to purchase
* @param options.groupId The id of the group purchasing a subscription
* @param options.email The email enter by the user on the Stripe form
* @param options.headers The request headers to store on analytics
* @return undefined
*/
api.payWithStripe = async function payWithStripe (options, stripeInc) {
let [
token,
user,
gift,
sub,
groupId,
email,
headers,
coupon,
] = options;
let response;
let subscriptionId;
// @TODO: We need to mock this, but curently we don't have correct Dependency Injection
let stripeApi = stripe;
if (stripeInc) stripeApi = stripeInc;
if (!token) throw new BadRequest('Missing req.body.id');
if (sub) {
if (sub.discount) {
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
coupon = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}).exec();
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
}
let customerObject = {
email,
metadata: { uuid: user._id },
card: token,
plan: sub.key,
};
if (groupId) {
customerObject.quantity = sub.quantity;
}
response = await stripeApi.customers.create(customerObject);
if (groupId) subscriptionId = response.subscriptions.data[0].id;
} else {
let amount = 500; // $5
if (gift) {
if (gift.type === 'subscription') {
amount = `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
} else {
amount = `${gift.gems.amount / 4 * 100}`;
}
}
response = await stripe.charges.create({
amount,
currency: 'usd',
card: token,
});
}
if (sub) {
await this.createSubscription({
user,
customerId: response.id,
paymentMethod: 'Stripe',
sub,
headers,
groupId,
subscriptionId,
});
} else {
let method = 'buyGems';
let data = {
user,
customerId: response.id,
paymentMethod: 'Stripe',
gift,
};
if (gift) {
let member = await User.findById(gift.uuid).exec();
gift.member = member;
if (gift.type === 'subscription') method = 'createSubscription';
data.paymentMethod = 'Gift';
}
await this[method](data);
}
};
/**
* Allows for purchasing a user subscription or group subscription with Amazon
*
* @param options
* @param options.billingAgreementId The Amazon billingAgreementId generated on the front end
* @param options.user The user object who is purchasing
* @param options.sub The subscription data to purchase
* @param options.coupon The coupon to discount the sub
* @param options.groupId The id of the group purchasing a subscription
* @param options.headers The request headers to store on analytics
* @return undefined
*/
api.subscribeWithAmazon = async function subscribeWithAmazon (options) {
let [
billingAgreementId,
sub,
coupon,
user,
groupId,
headers,
] = options;
if (!sub) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
if (!billingAgreementId) throw new BadRequest('Missing req.body.billingAgreementId');
if (sub.discount) { // apply discount
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
let result = await Coupon.findOne({_id: cc.validate(coupon), event: sub.key}).exec();
if (!result) throw new NotAuthorized(shared.i18n.t('invalidCoupon'));
}
await amzLib.setBillingAgreementDetails({
AmazonBillingAgreementId: billingAgreementId,
BillingAgreementAttributes: {
SellerNote: 'Habitica Subscription',
SellerBillingAgreementAttributes: {
SellerBillingAgreementId: shared.uuid(),
StoreName: 'Habitica',
CustomInformation: 'Habitica Subscription',
},
},
});
await amzLib.confirmBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
});
await amzLib.authorizeOnBillingAgreement({
AmazonBillingAgreementId: billingAgreementId,
AuthorizationReferenceId: shared.uuid().substring(0, 32),
AuthorizationAmount: {
CurrencyCode: 'USD',
Amount: sub.price,
},
SellerAuthorizationNote: 'Habitica Subscription Payment',
TransactionTimeout: 0,
CaptureNow: true,
SellerNote: 'Habitica Subscription Payment',
SellerOrderAttributes: {
SellerOrderId: shared.uuid(),
StoreName: 'Habitica',
},
});
await this.createSubscription({
user,
customerId: billingAgreementId,
paymentMethod: 'Amazon Payments',
sub,
headers,
groupId,
});
};
module.exports = api; module.exports = api;

View File

@@ -137,6 +137,7 @@ async function cronAsync (req, res) {
// Clear old completed todos - 30 days for free users, 90 for subscribers // Clear old completed todos - 30 days for free users, 90 for subscribers
// Do not delete challenges completed todos TODO unless the task is broken? // Do not delete challenges completed todos TODO unless the task is broken?
// Do not delete group completed todos
Tasks.Task.remove({ Tasks.Task.remove({
userId: user._id, userId: user._id,
type: 'todo', type: 'todo',
@@ -145,6 +146,7 @@ async function cronAsync (req, res) {
$lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(), $lt: moment(now).subtract(user.isSubscribed() ? 90 : 30, 'days').toDate(),
}, },
'challenge.id': {$exists: false}, 'challenge.id': {$exists: false},
'group.id': {$exists: false},
}).exec(); }).exec();
res.locals.wasModified = true; // TODO remove after v2 is retired res.locals.wasModified = true; // TODO remove after v2 is retired

View File

@@ -13,6 +13,7 @@ import { groupChatReceivedWebhook } from '../libs/webhook';
import { import {
InternalServerError, InternalServerError,
BadRequest, BadRequest,
NotAuthorized,
} from '../libs/errors'; } from '../libs/errors';
import baseModel from '../libs/baseModel'; import baseModel from '../libs/baseModel';
import { sendTxn as sendTxnEmail } from '../libs/email'; import { sendTxn as sendTxnEmail } from '../libs/email';
@@ -859,6 +860,11 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
let group = this; let group = this;
let update = {}; let update = {};
let plan = group.purchased.plan;
if (group.memberCount <= 1 && group.privacy === 'private' && plan && plan.customerId && !plan.dateTerminated) {
throw new NotAuthorized(shared.i18n.t('cannotDeleteActiveGroup'));
}
let challenges = await Challenge.find({ let challenges = await Challenge.find({
_id: {$in: user.challenges}, _id: {$in: user.challenges},
group: group._id, group: group._id,
@@ -899,6 +905,13 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
promises.push(group.remove()); promises.push(group.remove());
return await Bluebird.all(promises); return await Bluebird.all(promises);
} }
} else if (group.leader === user._id) { // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
let query = group.type === 'party' ? {'party._id': group._id} : {guilds: group._id};
query._id = {$ne: user._id};
let seniorMember = await User.findOne(query).select('_id').exec();
// could be missing in case of public guild (that can have 0 members) with 1 member who is leaving
if (seniorMember) update.$set = {leader: seniorMember._id};
} }
// otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for) // otherwise If the leader is leaving (or if the leader previously left, and this wasn't accounted for)
update.$inc = {memberCount: -1}; update.$inc = {memberCount: -1};
@@ -915,7 +928,16 @@ schema.methods.leave = async function leaveGroup (user, keep = 'keep-all') {
return await Bluebird.all(promises); return await Bluebird.all(promises);
}; };
schema.methods.updateTask = async function updateTask (taskToSync) { /**
* Updates all linked tasks for a group task
*
* @param taskToSync The group task that will be synced
* @param options.newCheckListItem The new checklist item that needs to be synced to all assigned users
* @param options.removedCheckListItem The removed checklist item that needs to be removed from all assigned users
*
* @return The created tasks
*/
schema.methods.updateTask = async function updateTask (taskToSync, options = {}) {
let group = this; let group = this;
let updateCmd = {$set: {}}; let updateCmd = {$set: {}};
@@ -926,14 +948,51 @@ schema.methods.updateTask = async function updateTask (taskToSync) {
} }
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required; updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
let taskSchema = Tasks[taskToSync.type]; let taskSchema = Tasks[taskToSync.type];
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update({ let updateQuery = {
userId: {$exists: true}, userId: {$exists: true},
'group.id': group.id, 'group.id': group.id,
'group.taskId': taskToSync._id, 'group.taskId': taskToSync._id,
}, updateCmd, {multi: true}).exec(); };
if (options.newCheckListItem) {
let newCheckList = {completed: false};
newCheckList.linkId = options.newCheckListItem.id;
newCheckList.text = options.newCheckListItem.text;
updateCmd.$push = { checklist: newCheckList };
}
if (options.removedCheckListItemId) {
updateCmd.$pull = { checklist: {linkId: {$in: [options.removedCheckListItemId]} } };
}
if (options.updateCheckListItems && options.updateCheckListItems.length > 0) {
let checkListIdsToRemove = [];
let checkListItemsToAdd = [];
options.updateCheckListItems.forEach(function gatherChecklists (updateCheckListItem) {
checkListIdsToRemove.push(updateCheckListItem.id);
let newCheckList = {completed: false};
newCheckList.linkId = updateCheckListItem.id;
newCheckList.text = updateCheckListItem.text;
checkListItemsToAdd.push(newCheckList);
});
updateCmd.$pull = { checklist: {linkId: {$in: checkListIdsToRemove} } };
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
delete updateCmd.$pull;
updateCmd.$push = { checklist: { $each: checkListItemsToAdd } };
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
return;
}
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update(updateQuery, updateCmd, {multi: true}).exec();
}; };
schema.methods.syncTask = async function groupSyncTask (taskToSync, user) { schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
@@ -952,11 +1011,13 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
if (userTags[i].name !== group.name) { if (userTags[i].name !== group.name) {
// update the name - it's been changed since // update the name - it's been changed since
userTags[i].name = group.name; userTags[i].name = group.name;
userTags[i].group = group._id;
} }
} else { } else {
userTags.push({ userTags.push({
id: group._id, id: group._id,
name: group.name, name: group.name,
group: group._id,
}); });
} }
@@ -982,6 +1043,17 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
} }
matchingTask.group.approval.required = taskToSync.group.approval.required; matchingTask.group.approval.required = taskToSync.group.approval.required;
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
// sync checklist
if (taskToSync.checklist) {
taskToSync.checklist.forEach(function syncCheckList (element) {
let newCheckList = {completed: false};
newCheckList.linkId = element.id;
newCheckList.text = element.text;
matchingTask.checklist.push(newCheckList);
});
}
if (!matchingTask.notes) matchingTask.notes = taskToSync.notes; // don't override the notes, but provide it if not provided if (!matchingTask.notes) matchingTask.notes = taskToSync.notes; // don't override the notes, but provide it if not provided
if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id); // add tag if missing if (matchingTask.tags.indexOf(group._id) === -1) matchingTask.tags.push(group._id); // add tag if missing

View File

@@ -1,8 +1,12 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import baseModel from '../libs/baseModel'; import baseModel from '../libs/baseModel';
import validator from 'validator';
export let schema = new mongoose.Schema({ export let schema = new mongoose.Schema({
planId: String, planId: String,
subscriptionId: String,
owner: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']},
quantity: {type: Number, default: 1},
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']} paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
customerId: String, // Billing Agreement Id in case of Amazon Payments customerId: String, // Billing Agreement Id in case of Amazon Payments
dateCreated: Date, dateCreated: Date,

View File

@@ -14,6 +14,7 @@ export let schema = new Schema({
}, },
name: {type: String, required: true}, name: {type: String, required: true},
challenge: {type: String}, challenge: {type: String},
group: {type: String},
}, { }, {
strict: true, strict: true,
minimize: false, // So empty objects are returned minimize: false, // So empty objects are returned

View File

@@ -206,6 +206,7 @@ let dailyTodoSchema = () => {
text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation text: {type: String, required: false, default: ''}, // required:false because it can be empty on creation
_id: false, _id: false,
id: {type: String, default: shared.uuid, required: true, validate: [validator.isUUID, 'Invalid uuid.']}, id: {type: String, default: shared.uuid, required: true, validate: [validator.isUUID, 'Invalid uuid.']},
linkId: {type: String},
}], }],
}; };
}; };

View File

@@ -16,6 +16,7 @@ const NOTIFICATION_TYPES = [
'GROUP_TASK_APPROVED', 'GROUP_TASK_APPROVED',
'LOGIN_INCENTIVE', 'LOGIN_INCENTIVE',
'GROUP_INVITE_ACCEPTED', 'GROUP_INVITE_ACCEPTED',
'SCORED_TASK',
]; ];
const Schema = mongoose.Schema; const Schema = mongoose.Schema;

View File

@@ -34,8 +34,9 @@
button(type='button', ng-click='User.deleteTag({params:{id:tag.id}})') button(type='button', ng-click='User.deleteTag({params:{id:tag.id}})')
span.glyphicon.glyphicon-trash span.glyphicon.glyphicon-trash
ul(ng-if='!_editing', hrpg-sort-tags) ul(ng-if='!_editing', hrpg-sort-tags)
li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: tag.challenge}', ng-repeat='tag in user.tags', bindonce='user.tags') li.filters-tags(ng-class='{active: user.filters[tag.id], challenge: showChallengeClass(tag)}', ng-repeat='tag in user.tags', bindonce='user.tags')
a(ng-click='toggleFilter(tag)') a(ng-click='toggleFilter(tag)')
span.glyphicon.glyphicon-bullhorn(ng-if="::tag.challenge") span.glyphicon.glyphicon-bullhorn(ng-if="::tag.challenge")
markdown(text='tag.name') markdown(text='tag.name')
| {{tag}}
// <li class="{#unless activeFilters(users[_userId].filters)}hidden{/}"> // <li class="{#unless activeFilters(users[_userId].filters)}hidden{/}">

View File

@@ -367,64 +367,4 @@ script(id='partials/options.settings.notifications.html', type="text/ng-template
small=env.t('unsubscribeAllEmailsText') small=env.t('unsubscribeAllEmailsText')
script(id='partials/options.settings.subscription.html',type='text/ng-template') include ./settings/subscription
//-h2=env.t('individualSub')
.container-fluid(ng-init='_subscription={key:"basic_earned"}')
h3= env.t('benefits')
.row
.col-md-6
+subPerks()
.container-fluid.slight-vertical-padding(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4=env.t('subscribeUsing')
.row.text-center
.col-xs-12.col-md-3
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-12.col-md-3
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-12.col-md-3
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))
.col-md-6
table.table.alert.alert-info(ng-if='user.purchased.plan.customerId')
tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!user.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='user.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[user.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[user.purchased.plan.planId].months}}', plan: '{{user.purchased.plan.paymentMethod}}'})
tr(ng-if='user.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{user.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{user.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{user.purchased.plan.consecutive.trinkets}}
div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')= env.t("resubscribe")
.form-group.reduce-top-margin
.radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit: "discount==true" | orderBy:"months"')
label
input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key')
span(ng-show='block.original')
span.label.label-success.line-through
| ${{:: block.original }}
=env.t('subscriptionRateText', {price:'{{::block.price}}', months: '{{::block.months}}'})
span(ng-hide='block.original')
=env.t('subscriptionRateText', {price: '{{::block.price}}', months: '{{block.months}}'})
.form-inline
.form-group
input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder'))
.form-group
button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply")
div(ng-if='user.purchased.plan.customerId')
.btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub')

View File

@@ -0,0 +1,63 @@
script(id='partials/options.settings.subscription.html',type='text/ng-template', ng-init="groupPane = 'subscription'")
//-h2=env.t('individualSub')
//- +groupSubscription
.container-fluid(ng-init='_subscription={key:"basic_earned"}')
h3= env.t('benefits')
.row
.col-md-6
+subPerks()
.col-md-6
table.table.alert.alert-info(ng-if='user.purchased.plan.customerId')
tr(ng-if='user.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(user.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!user.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='user.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[user.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[user.purchased.plan.planId].months}}', plan: '{{user.purchased.plan.paymentMethod}}'})
tr(ng-if='user.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{user.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='user.purchased.plan.consecutive.count || user.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{user.purchased.plan.consecutive.count + user.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{user.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{user.purchased.plan.consecutive.trinkets}}
div(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
h4(ng-if='(user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')= env.t("resubscribe")
.form-group.reduce-top-margin
.radio(ng-repeat='block in Content.subscriptionBlocks | toArray | omit: "discount==true" | orderBy:"months"', ng-if="block.type !== 'group'")
label
input(type="radio", name="subRadio", ng-value="block.key", ng-model='_subscription.key')
span(ng-show='block.original')
span.label.label-success.line-through
| ${{:: block.original }}
=env.t('subscriptionRateText', {price:'{{::block.price}}', months: '{{::block.months}}'})
span(ng-hide='block.original')
=env.t('subscriptionRateText', {price: '{{::block.price}}', months: '{{block.months}}'})
.form-inline
.form-group
input.form-control(type='text', ng-model='_subscription.coupon', placeholder= env.t('couponPlaceholder'))
.form-group
button.pull-right.btn.btn-small(type='button',ng-click='applyCoupon(_subscription.coupon)')= env.t("apply")
div(ng-if='user.purchased.plan.customerId')
.btn.btn-primary(ng-if='!user.purchased.plan.dateTerminated && user.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit()')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!user.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription()')=env.t('cancelSub')
.container-fluid.slight-vertical-padding(ng-if='!user.purchased.plan.customerId || (user.purchased.plan.customerId && user.purchased.plan.dateTerminated)')
small.muted=env.t('subscribeUsing')
.row.text-center
.col-xs-4
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-4
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-4
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))

View File

@@ -1,28 +0,0 @@
form.col-md-12.form-horizontal(ng-submit='create(newGroup)')
.form-group
label.control-label(for='new-group-name')=env.t('newGroupName', {groupType: "{{text}}"})
input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder=env.t('newGroupName', {groupType: "{{text}}"}), ng-model='newGroup.name')
.form-group
label(for='new-group-description')=env.t('description')
textarea.form-control#new-group-description.option-content(cols='3', placeholder=env.t('description'), ng-model='newGroup.description')
.form-group(ng-show='type=="guild"')
.radio
label
input(type='radio', name='new-group-privacy', value='public', ng-model='newGroup.privacy')
=env.t('public')
.radio
label
input(type='radio', name='new-group-privacy', value='private', ng-model='newGroup.privacy')
=env.t('inviteOnly')
br
input.btn.btn-default(type='submit', ng-disabled='!newGroup.privacy && !newGroup.name', value=env.t('create'))
span.gem-cost= '4 ' + env.t('gems')
p
small=env.t('gemCost')
.form-group
.checkbox
label
input(type='checkbox', ng-model='newGroup.leaderOnly.challenges')
=env.t('leaderOnlyChallenges')
.form-group(ng-show='type=="party"')
input.btn.btn-default.form-control(type='submit', value=env.t('create'))

View File

@@ -1,3 +1,6 @@
include ./groups/group-subscription
include ./groups/group-plans-benefits
a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter', popover-title=env.t('guildBankPop1'), popover=env.t('guildBankPop2'), popover-placement='left') a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter', popover-title=env.t('guildBankPop1'), popover=env.t('guildBankPop2'), popover-placement='left')
// <span class="task-action-btn tile flush bright add-gems-btn"></span> // <span class="task-action-btn tile flush bright add-gems-btn"></span>
span.task-action-btn.tile.flush.neutral span.task-action-btn.tile.flush.neutral
@@ -6,208 +9,174 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
=' ' + env.t('guildGems') =' ' + env.t('guildGems')
.container-fluid .container-fluid
.row ul.options-menu(ng-show='group.privacy === "private"')
.col-md-4 li
a(ng-click="groupPanel = 'chat'")=env.t('groupHomeTitle')
li(ng-show='group.purchased.active')
a(ng-click="groupPanel = 'tasks'")=env.t('groupTasksTitle')
li(ng-show='group.purchased.active && group.leader._id === user._id')
a(ng-click="groupPanel = 'approvals'")=env.t('approvalsTitle')
li
a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && group.purchased.plan.customerId')=env.t('subscription')
a(ng-click="groupPanel = 'subscription'", ng-show='group.leader._id === user._id && !group.purchased.plan.customerId')=env.t('upgradeTitle')
// ------ Bosses ------- .tab-content
+boss(false, false) .tab-pane.active
// ------ Information ------- .row(ng-show="groupPanel == 'chat'")
.panel.panel-default .col-md-4
.panel-heading(bindonce='group')
h3.panel-title
span {{group.name}}
span.group-leave-join(ng-if='group')
a.btn.btn-sm.btn-danger.pull-right(ng-if="isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)')
span.glyphicon.glyphicon-ban-circle
=env.t('leave')
a.btn.btn-success.pull-right(ng-if='!isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join')
span(ng-if='group.leader._id == user.id')
button.btn.btn-sm.btn-primary.pull-right(ng-click='cancelEdit(group)', ng-hide='!group._editing')=env.t('cancel')
button.btn.btn-sm.btn-primary.pull-right(ng-click='saveEdit(group)', ng-show='group._editing')=env.t('save')
button.btn.btn-sm.btn-default.pull-right(ng-click='editGroup(group)', ng-hide='group._editing')=env.t('editGroup')
.panel-body // ------ Bosses -------
form(ng-show='group._editing') +boss(false, false)
// ------ Information -------
.panel.panel-default
.panel-heading(bindonce='group')
h3.panel-title
span {{group.name}}
span.group-leave-join(ng-if='group')
a.btn.btn-sm.btn-danger.pull-right(ng-if="isMemberOfGroup(User.user._id, group)", ng-hide='group._editing', ng-click='clickLeave(group, $event)')
span.glyphicon.glyphicon-ban-circle
=env.t('leave')
a.btn.btn-success.pull-right(ng-if='!isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join')
span(ng-if='group.leader._id == user.id')
button.btn.btn-sm.btn-primary.pull-right(ng-click='cancelEdit(group)', ng-hide='!group._editing')=env.t('cancel')
button.btn.btn-sm.btn-primary.pull-right(ng-click='saveEdit(group)', ng-show='group._editing')=env.t('save')
button.btn.btn-sm.btn-default.pull-right(ng-click='editGroup(group)', ng-hide='group._editing')=env.t('editGroup')
.panel-body
form(ng-show='group._editing')
.form-group
label=env.t('groupName')
input.form-control(type='text', ng-model='groupCopy.name', placeholder=env.t('groupName'))
.form-group
label=env.t('description')
textarea.form-control(rows=6, ng-model='groupCopy.description')
include ../../shared/formatting-help
.form-group
label=env.t('logoUrl')
input.form-control(type='url', placeholder=env.t('logoUrl'), ng-model='groupCopy.logo')
.form-group
.checkbox
label
input(type='checkbox', ng-model='groupCopy.leaderOnly.challenges')
=env.t('leaderOnlyChallenges')
h4=env.t('assignLeader')
select#group-leader-selection(ng-model='groupCopy._newLeader', ng-options='member.profile.name for member in group.members')
div(ng-show='!group._editing')
img.img-rendering-auto.pull-right(ng-show='group.logo', ng-src='{{group.logo}}')
markdown(text='group.description')
br
p=env.t('groupLeader')
|:
a.badge.badge-info(ng-click='clickMember(group.leader._id, true)')
| {{group.leader.profile.name}}
.text-center(ng-if='group.type === "party"')
.row.row-margin: .col-sm-6.col-sm-offset-3
button.btn.btn-success.btn-block(
ng-if='!group.quest.key',
ng-click='clickStartQuest();'
)=env.t('startAQuest')
// ------ Members -------
.panel.panel-default
.panel-heading
h3.panel-title
=env.t('members')
span(ng-if='group.type=="party" && (group.onlineUsers || group.onlineUsers == 0)')= ' (' + env.t('onlineCount', {count: "{{group.onlineUsers}}"}) + ')'
button.pull-right.btn.btn-primary(ng-click="openInviteModal(group)")=env.t("inviteFriends")
.panel-body.modal-fixed-height
h4(ng-show='::group.memberCount === 1 && group.type === "party"')=env.t('partyEmpty')
table.table.table-striped(ng-show='::group.memberCount > 1 || group.type !== "party"' bindonce='group')
tr(ng-repeat='member in group.members track by member._id')
td.media
// allow leaders to ban members
.pull-left(ng-show='group.leader._id == user.id && member._id != user._id')
a.media-object(ng-click='removeMember(group, member, true)')
span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip'))
a.media-body
span(ng-click='clickMember(member._id, true)')
| {{member.profile.name}}
span(ng-click='clickMember(member._id, true)' ng-if='group.type === "party"')
| (#[strong {{member.stats.hp.toFixed(1)}}] #{env.t('hp')}) {{member.id === user.id ? ' ' + env.t('you') : ''}}
.pull-right(ng-if='group.type === "party"')
span.text-success {{member.online ? '&#9679 ' + env.t('online') : ''}}
tr(ng-if='::group.memberCount > group.members.length')
td
span.badge {{group.memberCount - group.members.length}}
= ' ' + env.t('moreMembers')
h4(ng-show='group.invites.length > 0')=env.t('invited')
table.table.table-striped
tr(ng-repeat='invite in group.invites')
td.media
// allow leaders to ban members
.pull-left(ng-show='group.leader._id == user.id')
a.media-object(ng-click='removeMember(group, invite, false)')
span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip'))
a.media-body
span(ng-click='clickMember(invite._id, true)')
| {{invite.profile.name}}
.panel.panel-default(ng-if='::group.type=="party" && group.memberCount > 1')
.panel-heading
h3.panel-title=env.t('partyList')
.panel-body
.form-group .form-group
label=env.t('groupName') select.form-control#partyOrder(
input.form-control(type='text', ng-model='groupCopy.name', placeholder=env.t('groupName')) ng-model='user.party.order',
.form-group ng-controller='ChatCtrl',
label=env.t('description') ng-options='k as v for (k , v) in partyOrderChoices',
textarea.form-control(rows=6, ng-model='groupCopy.description') ng-change='set({"party.order": user.party.order})'
include ../../shared/formatting-help )
.form-group | &nbsp;
label=env.t('logoUrl') select.form-control#partyOrderAscending(
input.form-control(type='url', placeholder=env.t('logoUrl'), ng-model='groupCopy.logo') ng-model='user.party.orderAscending',
.form-group ng-controller='ChatCtrl',
.checkbox ng-options='k as v for (k , v) in partyOrderAscendingChoices',
label ng-change='set({"party.orderAscending": user.party.orderAscending})'
input(type='checkbox', ng-model='groupCopy.leaderOnly.challenges') )
=env.t('leaderOnlyChallenges')
h4=env.t('assignLeader') include ./challenge-box
select#group-leader-selection(ng-model='groupCopy._newLeader', ng-options='member.profile.name for member in group.members')
div(ng-show='!group._editing') div(ng-if="group")
img.img-rendering-auto.pull-right(ng-show='group.logo', ng-src='{{group.logo}}') a.btn.btn-success(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join')
markdown(text='group.description')
br
p=env.t('groupLeader')
|:
a.badge.badge-info(ng-click='clickMember(group.leader._id, true)')
| {{group.leader.profile.name}}
.text-center(ng-if='group.type === "party"') .slight-vertical-padding
.row.row-margin: .col-sm-6.col-sm-offset-3 small.muted=env.t('groupID')
button.btn.btn-success.btn-block( | : {{group._id}}
ng-if='!group.quest.key', .slight-vertical-padding(ng-if='group.type === "party" && group.memberCount === 1')
ng-click='clickStartQuest();' small.muted=env.t('userId')
)=env.t('startAQuest') | : {{user._id}}
// ------ Members ------- .col-md-8
.panel.panel-default div
.panel-heading textarea.form-control(ng-show='group._editing', rows=6, placeholder=env.t('leaderMsg'), ng-model='groupCopy.leaderMessage')
h3.panel-title .slight-vertical-padding
=env.t('members') table(ng-show='group.leaderMessage')
span(ng-if='group.type=="party" && (group.onlineUsers || group.onlineUsers == 0)')= ' (' + env.t('onlineCount', {count: "{{group.onlineUsers}}"}) + ')' tr
button.pull-right.btn.btn-primary(ng-click="openInviteModal(group)")=env.t("inviteFriends")
.panel-body.modal-fixed-height
h4(ng-show='::group.memberCount === 1 && group.type === "party"')=env.t('partyEmpty')
table.table.table-striped(ng-show='::group.memberCount > 1 || group.type !== "party"' bindonce='group')
tr(ng-repeat='member in group.members track by member._id')
td.media
// allow leaders to ban members
.pull-left(ng-show='group.leader._id == user.id && member._id != user._id')
a.media-object(ng-click='removeMember(group, member, true)')
span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip'))
a.media-body
span(ng-click='clickMember(member._id, true)')
| {{member.profile.name}}
span(ng-click='clickMember(member._id, true)' ng-if='group.type === "party"')
| (#[strong {{member.stats.hp.toFixed(1)}}] #{env.t('hp')}) {{member.id === user.id ? ' ' + env.t('you') : ''}}
.pull-right(ng-if='group.type === "party"')
span.text-success {{member.online ? '&#9679 ' + env.t('online') : ''}}
tr(ng-if='::group.memberCount > group.members.length')
td td
span.badge {{group.memberCount - group.members.length}} .popover.static-popover.fade.right.in.wide-popover
= ' ' + env.t('moreMembers') .arrow
h4(ng-show='group.invites.length > 0')=env.t('invited') h3.popover-title {{group.leader.profile.name}}
table.table.table-striped .popover-content
tr(ng-repeat='invite in group.invites') markdown(text='group._editing ? groupCopy.leaderMessage : group.leaderMessage')
td.media
// allow leaders to ban members
.pull-left(ng-show='group.leader._id == user.id')
a.media-object(ng-click='removeMember(group, invite, false)')
span.glyphicon.glyphicon-ban-circle(tooltip=env.t('banTip'))
a.media-body
span(ng-click='clickMember(invite._id, true)')
| {{invite.profile.name}}
.panel.panel-default(ng-if='::group.type=="party" && group.memberCount > 1') div(ng-controller='ChatCtrl')
.panel-heading .alert.alert-info.alert-sm(ng-if='group.memberCount > Shared.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF')=env.t('largeGroupNote')
h3.panel-title=env.t('partyList') h3=env.t('chat')
.panel-body include ./chat-box
.form-group
select.form-control#partyOrder(
ng-model='user.party.order',
ng-controller='ChatCtrl',
ng-options='k as v for (k , v) in partyOrderChoices',
ng-change='set({"party.order": user.party.order})'
)
| &nbsp;
select.form-control#partyOrderAscending(
ng-model='user.party.orderAscending',
ng-controller='ChatCtrl',
ng-options='k as v for (k , v) in partyOrderAscendingChoices',
ng-change='set({"party.orderAscending": user.party.orderAscending})'
)
include ./challenge-box +chatMessages()
h4(ng-if='group.chat.length < 1 && group.type === "party"')=env.t('partyChatEmpty')
h4(ng-if='group.chat.length < 1 && group.type === "guild"')=env.t('guildChatEmpty')
div(ng-if="group") group-tasks(ng-show="groupPanel == 'tasks'")
a.btn.btn-success(ng-if=':: !isMemberOfGroup(User.user._id, group)', ng-click='join(group)')=env.t('join')
.slight-vertical-padding group-approvals(ng-show="groupPanel == 'approvals'", ng-if="group.leader._id === user._id", group="group")
small.muted=env.t('groupID')
| : {{group._id}}
.slight-vertical-padding(ng-if='group.type === "party" && group.memberCount === 1')
small.muted=env.t('userId')
| : {{user._id}}
.col-md-8 +groupSubscription
div
textarea.form-control(ng-show='group._editing', rows=6, placeholder=env.t('leaderMsg'), ng-model='groupCopy.leaderMessage')
.slight-vertical-padding
table(ng-show='group.leaderMessage')
tr
td
.popover.static-popover.fade.right.in.wide-popover
.arrow
h3.popover-title {{group.leader.profile.name}}
.popover-content
markdown(text='group._editing ? groupCopy.leaderMessage : group.leaderMessage')
ul.options-menu(ng-init="groupPane = 'chat'", ng-hide="true")
//- li
//- a(ng-click="groupPane = 'chat'")=env.t('chat')
//- li
//- a(ng-click="groupPane = 'tasks'", ng-show='group.purchased.active')=env.t('tasks')
//- li
//- a(ng-click="groupPane = 'approvals'", ng-show='group.purchased.active && group.leader._id === user._id')=env.t('approvals')
//- li
//- a(ng-click="groupPane = 'subscription'", ng-show='group.leader._id === user._id')=env.t('subscription')
.tab-content
.tab-pane.active
div(ng-controller='ChatCtrl', ng-show="groupPane == 'chat'")
.alert.alert-info.alert-sm(ng-if='group.memberCount > Shared.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF')=env.t('largeGroupNote')
h3=env.t('chat')
include ./chat-box
+chatMessages()
h4(ng-if='group.chat.length < 1 && group.type === "party"')=env.t('partyChatEmpty')
h4(ng-if='group.chat.length < 1 && group.type === "guild"')=env.t('guildChatEmpty')
group-tasks(ng-show="groupPane == 'tasks'")
group-approvals(ng-show="groupPane == 'approvals'", ng-if="group.leader._id === user._id", group="group")
//TODO: This can be a directive and the group/user can be an object passed via attribute
div(ng-show="groupPane == 'subscription'")
.col-md-12
table.table.alert.alert-info(ng-if='group.purchased.plan.customerId')
tr(ng-if='group.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(group.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!group.purchased.plan.dateTerminated'): td
h4=env.t('subscribed')
p(ng-if='group.purchased.plan.planId')=env.t('purchasedPlanId', {price: '{{Content.subscriptionBlocks[group.purchased.plan.planId].price}}', months: '{{Content.subscriptionBlocks[group.purchased.plan.planId].months}}', plan: '{{group.purchased.plan.paymentMethod}}'})
tr(ng-if='group.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{group.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{group.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{group.purchased.plan.consecutive.trinkets}}
div(ng-if='group.purchased.plan.customerId')
.btn.btn-primary(ng-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit({groupId: group.id})')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!group.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription({group: group})')=env.t('cancelSub')
.container-fluid.slight-vertical-padding(ng-if='!group.purchased.plan.customerId || (group.purchased.plan.customerId && group.purchased.plan.dateTerminated)', ng-init="_subscription.key='group_monthly'")
small.muted=env.t('subscribeUsing')
.row.text-center
.col-xs-4
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})', ng-disabled='!_subscription.key')= env.t('card')
.col-xs-4
a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.col-xs-4
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))

View File

@@ -0,0 +1,29 @@
mixin groupCreateForm()
form.col-md-12.form-horizontal(ng-submit='create(newGroup)')
.form-group
label.control-label(for='new-group-name')=env.t('newGroupName', {groupType: "{{text}}"})
input.form-control#new-group-name.input-medium.option-content(required, type='text', placeholder=env.t('newGroupName', {groupType: "{{text}}"}), ng-model='newGroup.name')
.form-group
label(for='new-group-description')=env.t('description')
textarea.form-control#new-group-description.option-content(cols='3', placeholder=env.t('description'), ng-model='newGroup.description')
.form-group(ng-show='type=="guild"')
.radio
label
input(type='radio', name='new-group-privacy', value='public', ng-model='newGroup.privacy')
=env.t('public')
.radio
label
input(type='radio', name='new-group-privacy', value='private', ng-model='newGroup.privacy')
=env.t('inviteOnly')
br
input.btn.btn-default(type='submit', ng-disabled='!newGroup.privacy && !newGroup.name', value=env.t('create'))
span.gem-cost= '4 ' + env.t('gems')
p
small=env.t('gemCost')
.form-group
.checkbox
label
input(type='checkbox', ng-model='newGroup.leaderOnly.challenges')
=env.t('leaderOnlyChallenges')
.form-group(ng-show='type=="party"')
input.btn.btn-default.form-control(type='submit', value=env.t('create'))

View File

@@ -0,0 +1,31 @@
mixin groupPlansBenefits()
h2.text-center=env.t('groupBenefitsTitle')
.row(style="font-size: 2rem;")
.col-md-6.col-md-offset-3=env.t('groupBenefitsDescription')
.row
.col-md-5.col-md-offset-4
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitOneTitle')
span=env.t('groupBenefitOneDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitTwoTitle')
span=env.t('groupBenefitTwoDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitThreeTitle')
span=env.t('groupBenefitThreeDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitFourTitle')
span=env.t('groupBenefitFourDescription')
div
h3
span.glyphicon.glyphicon-ok-circle(style='margin-right: 1.5rem;')
=env.t('groupBenefitFiveTitle')
span=env.t('groupBenefitFiveDescription')

View File

@@ -0,0 +1,40 @@
include ./create-group
script(type='text/ng-template', id='partials/options.social.groupPlans.html')
div(ng-show='activePage === PAGES.BENEFITS')
+groupPlansBenefits
br
br
.row
.col-sm-6.col-sm-offset-3
a.btn.btn-primary.btn-lg.btn-block(ng-click="changePage(PAGES.CREATE_GROUP)")=env.t('createAGroup')
div(ng-show='activePage === PAGES.CREATE_GROUP')
h2.text-center=env.t('createAGroup')
.col-xs-12
+groupCreateForm
br
br
.row
.col-sm-6.col-sm-offset-3
a.btn.btn-primary.btn-lg.btn-block(ng-click="createGroup()", ng-disabled="!newGroupIsReady()")=env.t('create')
div(ng-show='activePage === PAGES.UPGRADE_GROUP')
h2.text-center=env.t('upgradeTitle')
.row.text-center
.col-md-6.col-md-offset-3
a.purchase.btn.btn-primary(ng-click='upgradeGroup(PAYMENTS.STRIPE)')=env.t('card')
a.purchase(ng-click='upgradeGroup(PAYMENTS.AMAZON)')
img(src='https://payments.amazon.com/gp/cba/button', alt=env.t('amazonPayments'))
//- .col-xs-4
//- a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
//- img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.row
.col-md-6.col-md-offset-3
br
.text-center=env.t('groupSubscriptionPrice')

View File

@@ -0,0 +1,50 @@
// @TODO: This can be a directive and the group/user can be an object passed via attribute
mixin groupSubscription()
div(ng-show="groupPanel == 'subscription'")
.col-md-12
.col-md-12
div(ng-hide="group.purchased.plan.customerId")
+groupPlansBenefits
.row
.col-md-12
br
table.table.alert.alert-info(ng-if='group.purchased.plan.customerId')
tr(ng-if='group.purchased.plan.dateTerminated'): td.alert.alert-warning
span.noninteractive-button.btn-danger=env.t('canceledSubscription')
i.glyphicon.glyphicon-time
| #{env.t('subCanceled')} <strong>{{moment(group.purchased.plan.dateTerminated).format('MM/DD/YYYY')}}</strong>
tr(ng-if='!group.purchased.plan.dateTerminated'): td
h3=env.t('subscribed')
p(ng-if='group.purchased.plan.planId')=env.t('groupSubscriptionPrice')
tr(ng-if='group.purchased.plan.extraMonths'): td
span.glyphicon.glyphicon-credit-card
| &nbsp;#{env.t('purchasedPlanExtraMonths', {months: '{{group.purchased.plan.extraMonths | number:2}}'})}
tr(ng-if='group.purchased.plan.consecutive.count || group.purchased.plan.consecutive.offset'): td
span.glyphicon.glyphicon-forward
| &nbsp;#{env.t('consecutiveSubscription')}
ul.list-unstyled
li #{env.t('consecutiveMonths')} {{group.purchased.plan.consecutive.count + group.purchased.plan.consecutive.offset}}
li #{env.t('gemCapExtra')} {{group.purchased.plan.consecutive.gemCapExtra}}
li #{env.t('mysticHourglasses')} {{group.purchased.plan.consecutive.trinkets}}
div(ng-if='group.purchased.plan.customerId')
.btn.btn-primary(ng-if='!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod=="Stripe"', ng-click='Payments.showStripeEdit({groupId: group.id})')=env.t('subUpdateCard')
.btn.btn-sm.btn-danger(ng-if='!group.purchased.plan.dateTerminated', ng-click='Payments.cancelSubscription({group: group})')=env.t('cancelSub')
.row
.col-md-12(ng-if='!group.purchased.plan.customerId || (group.purchased.plan.customerId && group.purchased.plan.dateTerminated)', ng-init="_subscription.key='group_monthly'")
.row.text-center
h3 Upgrade My Group
div
a.purchase.btn.btn-primary(ng-click='Payments.showStripe({subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})', ng-disabled='!_subscription.key')= env.t('card')
a.purchase(ng-click="Payments.amazonPayments.init({type: 'subscription', subscription:_subscription.key, coupon:_subscription.coupon, groupId: group.id})")
img(src='https://payments.amazon.com/gp/cba/button',alt=env.t('amazonPayments'))
//- .col-xs-4
//- a.purchase(href='/paypal/subscribe?_id={{user._id}}&apiToken={{User.settings.auth.apiToken}}&sub={{_subscription.key}}{{_subscription.coupon ? "&coupon="+_subscription.coupon : ""}}&groupId={{group.id}}', ng-disabled='!_subscription.key')
//- img(src='https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-small.png',alt=env.t('paypal'))
.row(ng-if='!group.purchased.plan.customerId')
.col-md-6.col-md-offset-3
br
.text-center=env.t('groupSubscriptionPrice')

View File

@@ -1,6 +1,13 @@
script(type='text/ng-template', id='partials/groups.tasks.approvals.html') script(type='text/ng-template', id='partials/groups.tasks.approvals.html')
.panel-group(ng-repeat="approval in approvals") .row(style="margin-bottom: 2rem;")
.col-md-12
button.btn.btn-primary(ng-click='refreshApprovals()', ng-hide="loading")=env.t('refreshApprovals')
button.btn.btn-primary(ng-disabled="true", ng-show="loading")=env.t('loading')
.well(ng-show="group.approvals.length === 0")=env.t('blankApprovalsDescription')
.panel-group(ng-repeat="approval in group.approvals")
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
span {{approvalTitle(approval)}} span {{approvalTitle(approval)}}
a.btn.btn-sm.btn-success.pull-right(ng-click="approve(approval.group.taskId, approval.userId._id)")=env.t('approve') a.btn.btn-sm.btn-success.pull-right(ng-click="approve(approval.group.taskId, approval.userId._id, approval.userId.profile.name, $index)")=env.t('approve')

View File

@@ -4,4 +4,10 @@ include ./group-members-autocomplete
include ./group-tasks-approvals include ./group-tasks-approvals
script(type='text/ng-template', id='partials/groups.tasks.html') script(type='text/ng-template', id='partials/groups.tasks.html')
habitrpg-tasks(main=false) .row(style="margin-bottom: 2rem;")
.col-md-12
button.btn.btn-primary(ng-click='refreshTasks()', ng-hide="loading")=env.t('refreshGroupTasks')
button.btn.btn-primary(ng-disabled="true", ng-show="loading")=env.t('loading')
.row
habitrpg-tasks(main=false)

View File

@@ -7,6 +7,8 @@ include ./quests/index
include ./chat-message include ./chat-message
include ./party include ./party
include ./groups/group-tasks include ./groups/group-tasks
include ./groups/group-plans
include ./groups/create-group
script(type='text/ng-template', id='partials/options.social.inbox.html') script(type='text/ng-template', id='partials/options.social.inbox.html')
.options-blurbmenu .options-blurbmenu
@@ -65,7 +67,7 @@ script(type='text/ng-template', id='partials/options.social.guilds.detail.html')
script(type='text/ng-template', id='partials/options.social.guilds.create.html') script(type='text/ng-template', id='partials/options.social.guilds.create.html')
div.col-xs-12 div.col-xs-12
include ./create-group +groupCreateForm
script(type='text/ng-template', id='partials/options.social.guilds.html') script(type='text/ng-template', id='partials/options.social.guilds.html')
ul.options-submenu ul.options-submenu
@@ -105,6 +107,9 @@ script(type='text/ng-template', id='partials/options.social.html')
li(ng-class="{ active: $state.includes('options.social.hall') }") li(ng-class="{ active: $state.includes('options.social.hall') }")
a(ui-sref='options.social.hall.heroes') a(ui-sref='options.social.hall.heroes')
=env.t('hall') =env.t('hall')
li(ng-class="{ active: $state.includes('options.social.groupPlans') }")
a(ui-sref='options.social.groupPlans')
=env.t('groupPlansTitle')
.tab-content .tab-content
.tab-pane.active .tab-pane.active

View File

@@ -36,6 +36,8 @@ nav.toolbar(ng-controller='MenuCtrl')
a(ui-sref='options.social.challenges')=env.t('challenges') a(ui-sref='options.social.challenges')=env.t('challenges')
li li
a(ui-sref='options.social.hall.heroes')=env.t('hall') a(ui-sref='options.social.hall.heroes')=env.t('hall')
li
a(ui-sref='options.social.groupPlans')=env.t('groupPlansTitle')
ul.toolbar-submenu ul.toolbar-submenu
li li
a(ui-sref='options.inventory.drops')=env.t('market') a(ui-sref='options.inventory.drops')=env.t('market')
@@ -120,6 +122,8 @@ nav.toolbar(ng-controller='MenuCtrl')
a(ui-sref='options.social.challenges')=env.t('challenges') a(ui-sref='options.social.challenges')=env.t('challenges')
li li
a(ui-sref='options.social.hall.heroes')=env.t('hall') a(ui-sref='options.social.hall.heroes')=env.t('hall')
li
a(ui-sref='options.social.groupPlans')=env.t('groupPlansTitle')
li.toolbar-button-dropdown li.toolbar-button-dropdown
a(ui-sref='options.inventory.drops', data-close-menu) a(ui-sref='options.inventory.drops', data-close-menu)
span=env.t('inventory') span=env.t('inventory')

View File

@@ -24,6 +24,7 @@ include ./enable-desktop-notifications.jade
include ./login-incentives.jade include ./login-incentives.jade
include ./login-incentives-reward-unlocked.jade include ./login-incentives-reward-unlocked.jade
include ./generic.jade include ./generic.jade
include ./tasks-edit.jade
//- Settings //- Settings
script(type='text/ng-template', id='modals/change-day-start.html') script(type='text/ng-template', id='modals/change-day-start.html')

View File

@@ -0,0 +1,7 @@
script(type='text/ng-template', id='modals/task-edit.html')
.modal-content.task-modal(style='min-width:28em', class='{{taskStatus}}', id="task-{{task._id}}")
.modal-body.text-center(style='padding-bottom:0')
| {{scope}}
include ../tasks/edit/index
.modal-footer(style='margin-top:0')
.container-fluid

View File

@@ -1,5 +1,6 @@
div(ng-if='task._editing') div(ng-if='task._editing')
.task-options .task-options
h2 {{task._edit.text}}
// Broken Challenge // Broken Challenge
.well(ng-if='task.challenge.broken') .well(ng-if='task.challenge.broken')
@@ -33,7 +34,7 @@ div(ng-if='task._editing')
include ./checklist include ./checklist
form(ng-submit='saveTask(task,false,true)') form
include ./text_notes include ./text_notes
include ./habits/plus_minus include ./habits/plus_minus
@@ -49,4 +50,11 @@ div(ng-if='task._editing')
include ./advanced_options include ./advanced_options
.save-close .save-close
button(type='submit')=env.t('saveAndClose') button(type='submit', ng-click='saveTask(task,false,true); $close()')=env.t('saveAndClose')
br
br
.save-close
button(ng-click='cancelTaskEdit(task); $close()')=env.t('cancel')

View File

@@ -1,5 +1,5 @@
fieldset.option-group(ng-if='!$state.includes("options.social.challenges")') fieldset.option-group(ng-if='!$state.includes("options.social.challenges") && !obj.leader')
p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags') p.option-title.mega(ng-class='{active: task._tags}', ng-click='task._tags = !task._tags', tooltip=env.t('expandCollapse'))=env.t('tags')
label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags') label.checkbox(ng-repeat='tag in user.tags', ng-if='task._tags', style='text-align: left;')
input(type='checkbox', ng-checked="task.tags.indexOf(tag.id) !== -1", ng-click="updateTaskTags(tag.id, task)") input(type='checkbox', ng-checked="task.tags.indexOf(tag.id) !== -1", ng-click="updateTaskTags(tag.id, task)")
markdown(text='tag.name') markdown(text='tag.name')

View File

@@ -6,7 +6,7 @@ include ./task_view/mixins
script(id='templates/habitrpg-tasks.html', type="text/ng-template") script(id='templates/habitrpg-tasks.html', type="text/ng-template")
.tasks-lists.container-fluid .tasks-lists.container-fluid
.row .row
.col-md-3.col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-3": obj.type }') .col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-3": !obj.type }')
.task-column(class='{{::list.type}}s') .task-column(class='{{::list.type}}s')
include ./task_view/graph include ./task_view/graph

View File

@@ -4,6 +4,13 @@
span(ng-if='task.type=="todo" && task.date') span(ng-if='task.type=="todo" && task.date')
span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}} span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}}
// Approval requested
| &nbsp;
span(ng-show='task.group.approval.requested && !task.group.approval.approved')
span(tooltip=env.t('approvalRequested'))
span=env.t('approvalRequested')
| &nbsp;
// Streak // Streak
| &nbsp; | &nbsp;
span(ng-show='task.streak') {{task.streak}}&nbsp; span(ng-show='task.streak') {{task.streak}}&nbsp;
@@ -24,18 +31,21 @@
a.badge(ng-if='task.checklist[0]', ng-class='{"badge-success":checklistCompletion(task.checklist) == task.checklist.length}', ng-click='collapseChecklist(task)', tooltip=env.t('expandCollapse')) a.badge(ng-if='task.checklist[0]', ng-class='{"badge-success":checklistCompletion(task.checklist) == task.checklist.length}', ng-click='collapseChecklist(task)', tooltip=env.t('expandCollapse'))
|{{checklistCompletion(task.checklist)}}/{{task.checklist.length}} |{{checklistCompletion(task.checklist)}}/{{task.checklist.length}}
span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)') span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)')
// edit // edit
a(ng-hide='task._editing', ng-click='editTask(task, user)', tooltip=env.t('edit')) a(ng-hide='task._editing || (checkGroupAccess && !checkGroupAccess(obj))', ng-click='editTask(task, user, Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main))', tooltip=env.t('edit'))
| &nbsp; | &nbsp;
span.glyphicon.glyphicon-pencil(ng-hide='task._editing') span.glyphicon.glyphicon-pencil(ng-hide='task._editing')
| &nbsp; | &nbsp;
a(ng-hide='!task._editing', ng-click='cancelTaskEdit(task)', tooltip=env.t('cancel')) a(ng-hide='!task._editing', ng-click='cancelTaskEdit(task)', tooltip=env.t('cancel'))
span.glyphicon.glyphicon-remove(ng-hide='!task._editing') span.glyphicon.glyphicon-remove(ng-hide='!task._editing')
| &nbsp; | &nbsp;
// save // save
a(ng-hide='!task._editing', ng-click='saveTask(task)', tooltip=env.t('save')) a(ng-hide='!task._editing', ng-click='saveTask(task)', tooltip=env.t('save'))
span.glyphicon.glyphicon-ok(ng-hide='!task._editing') span.glyphicon.glyphicon-ok(ng-hide='!task._editing')
| &nbsp; | &nbsp;
//challenges //challenges
span(ng-if='task.challenge.id') span(ng-if='task.challenge.id')
span(ng-if='task.challenge.broken') span(ng-if='task.challenge.broken')
@@ -44,8 +54,9 @@
span(ng-if='!task.challenge.broken') span(ng-if='!task.challenge.broken')
span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge')) span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge'))
| &nbsp; | &nbsp;
// delete // delete
a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-click='removeTask(task, obj)', tooltip=env.t('delete')) a(ng-if='!task.challenge.id || obj.leader._id === User.user._id', ng-hide="(checkGroupAccess && !checkGroupAccess(obj))" ng-click='removeTask(task, obj)', tooltip=env.t('delete'))
span.glyphicon.glyphicon-trash span.glyphicon.glyphicon-trash
| &nbsp; | &nbsp;

View File

@@ -5,13 +5,11 @@ li(id='task-{{::task._id}}',
ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)', ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)',
ng-show='shouldShow(task, list, user.preferences)', ng-show='shouldShow(task, list, user.preferences)',
popover-trigger='mouseenter', popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}', popover-trigger='mouseenter', popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}',
data-popover-html="{{task.popoverOpen ? '' : task.notes | markdown}}") data-popover-html="{{::taskPopover(task) | markdown}}")
ng-form(name='taskForm') ng-form(name='taskForm')
include ./meta_controls include ./meta_controls
include ./task_view/index include ./task_view/index
include ./edit/index
div(class='{{obj._id}}{{task._id}}-chart', ng-show='charts[obj._id+task._id]') div(class='{{obj._id}}{{task._id}}-chart', ng-show='charts[obj._id+task._id]')

View File

@@ -1,4 +1,4 @@
form.task-add(name='new{{list.type}}form', ng-hide='obj._locked', ng-submit='addTask(list, obj)', novalidate) form.task-add(name='new{{list.type}}form', ng-hide='obj._locked || (checkGroupAccess && !checkGroupAccess(obj))', ng-submit='addTask(list, obj)', novalidate)
textarea(rows='6', focus-element='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(list, obj)"}', required) textarea(rows='6', focus-element='list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolderBulk}}', ng-if='list.bulk', ui-keydown='{"meta-enter ctrl-enter":"addTask(list, obj)"}', required)
input(type='text', focus-element='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required) input(type='text', focus-element='!list.bulk && list.focus', ng-model='list.newTask', placeholder='{{list.placeHolder}}', ng-if='!list.bulk', required)
button(type='submit', ng-disabled='new{{list.type}}form.$invalid') button(type='submit', ng-disabled='new{{list.type}}form.$invalid')

View File

@@ -27,7 +27,7 @@
span.reward-cost {{task.value}} span.reward-cost {{task.value}}
// Daily & Todos // Daily & Todos
span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"') span.task-checker.action-yesno(ng-if='::task.type=="daily" || task.type=="todo"', ng-class='{"group-yesno": !!obj.leader}')
input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox', input.task-input.visuallyhidden.focusable(id='box-{{::obj._id}}_{{::task._id}}', type='checkbox',
ng-model='task.completed', ng-if='$state.includes("tasks")', ng-model='task.completed', ng-if='$state.includes("tasks")',
ng-change='changeCheck(task)' ng-change='changeCheck(task)'