From f21e800b0b80b30e9977c402c215568e9e595763 Mon Sep 17 00:00:00 2001 From: Fiz <34069775+Hafizzle@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:02:47 -0600 Subject: [PATCH] Group Plan Modal (#15588) * Add group plan selection modal for upgrades Allow users to select an existing group to upgrade before creating a new one. * crlf -> lf lint * set selection of group plan Also tiny UI fixes * Update group plan selection to include expired plans * Add includeExpiredPlans option to group fetching * force flag when fetching group plans * Update group plan eligibility check * Fix eslint error in push notification import * replace chaining (?.) w/null check * Remove comment * set initial selected group plan, and fix card rounding * format member count * Show warning for pending party invites when upgrading to paid group plan Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites * suppress error toasts for group modal, and UI tweaks for group modal suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans * Clear upgradingGroup state after group plan payment --- website/client/src/app.vue | 5 + .../group-plans/groupPlanSelectionModal.vue | 577 ++++++++++++++++++ .../src/components/static/groupPlans.vue | 5 +- .../src/components/ui/selectableCard.vue | 92 +++ website/client/src/pages/user-main.vue | 4 + website/common/locales/en/groups.json | 14 +- website/server/controllers/api-v3/groups.js | 10 +- website/server/libs/inbox/index.js | 2 +- website/server/libs/payments/subscriptions.js | 4 + website/server/models/group.js | 22 +- 10 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 website/client/src/components/group-plans/groupPlanSelectionModal.vue create mode 100644 website/client/src/components/ui/selectableCard.vue diff --git a/website/client/src/app.vue b/website/client/src/app.vue index a49c648541..e454bf53e2 100644 --- a/website/client/src/app.vue +++ b/website/client/src/app.vue @@ -229,6 +229,11 @@ export default { } return Promise.resolve(error); } + if (error.response.status === 404 + && error.response.config.method === 'get' + && error.response.config.url.indexOf('/api/v4/groups/party') !== -1) { + return Promise.reject(error); + } } const errorData = error.response.data; diff --git a/website/client/src/components/group-plans/groupPlanSelectionModal.vue b/website/client/src/components/group-plans/groupPlanSelectionModal.vue new file mode 100644 index 0000000000..118b80bdbc --- /dev/null +++ b/website/client/src/components/group-plans/groupPlanSelectionModal.vue @@ -0,0 +1,577 @@ + + + + + + + diff --git a/website/client/src/components/static/groupPlans.vue b/website/client/src/components/static/groupPlans.vue index 2bcedada89..7bd2d16b9f 100644 --- a/website/client/src/components/static/groupPlans.vue +++ b/website/client/src/components/static/groupPlans.vue @@ -1,5 +1,6 @@ + + + + diff --git a/website/client/src/pages/user-main.vue b/website/client/src/pages/user-main.vue index aa223b7444..8df3031c76 100644 --- a/website/client/src/pages/user-main.vue +++ b/website/client/src/pages/user-main.vue @@ -295,6 +295,10 @@ export default { appState = JSON.parse(appState); if (appState.paymentCompleted) { removeLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE); + if (appState.paymentType === 'groupPlan') { + this.$store.state.upgradingGroup = {}; + this.$store.dispatch('guilds:getGroupPlans', true); + } this.$root.$emit('habitica:payment-success', appState); } } diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 9a08a33a12..5cacc54956 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -428,5 +428,17 @@ "interestedLearningMore": "Interested in Learning More?", "checkGroupPlanFAQ": "Check out the Group Plans FAQ to learn how to get the most out of your shared task experience.", "groupPlanBillingFYI": "Group Plan subscriptions automatically renew unless you cancel at least 24 hours before the end of your current period. You can cancel from the Group Billing tab of your Group Plan. You will be charged within 24 hours before your subscription renews, based on the number of members in your Group Plan at that time. If you add members between payment periods, you'll see an additional prorated charge for their benefits at your next billing cycle.", - "groupPlanBillingFYIShort": "Group Plan subscriptions automatically renew unless you cancel at least 24 hours before the end of your current period. You will be charged within 24 hours before your subscription renews, based on the number of members in your Group Plan at that time. If you add members between payment periods, you'll see an additional prorated charge for their benefits at your next billing cycle." + "groupPlanBillingFYIShort": "Group Plan subscriptions automatically renew unless you cancel at least 24 hours before the end of your current period. You will be charged within 24 hours before your subscription renews, based on the number of members in your Group Plan at that time. If you add members between payment periods, you'll see an additional prorated charge for their benefits at your next billing cycle.", + "chooseAnOption": "Choose an Option", + "upgradeExistingGroup": "Upgrade an Existing Group", + "createNewGroup": "Create a New Group", + "yourParty": "Your Party", + "previouslyUpgradedGroup": "Previously upgraded Group", + "inviteOthersForAdditional": "Invite others to your Group for an additional", + "perMember": "per member", + "additionalMembersProrated": "Additional members invited during the month will be added to the next billing cycle's total as a pro-rated charge.", + "oneMember": "1 member", + "membersCount": "<%= count %> members", + "pendingCount": "(<%= count %> pending)", + "upgradeCancelsPendingInvites": "Upgrading your Party will cancel all pending invites" } diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index 159adb8466..93c7eb04da 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -334,9 +334,13 @@ api.getGroups = { throw new BadRequest(apiError('guildsOnlyPaginate')); } - const groupFields = basicGroupFields.concat(' description memberCount balance leaderOnly'); + let groupFields = basicGroupFields.concat(' description memberCount balance leaderOnly'); const sort = '-memberCount'; + if (req.query.includeExpiredPlans === 'true') { + groupFields = groupFields.concat(' purchased'); + } + const filters = {}; if (req.query.categories) { const categorySlugs = req.query.categories.split(','); @@ -371,6 +375,10 @@ api.getGroups = { filters.$or.push({ description: searchQuery }); } + if (req.query.includeExpiredPlans === 'true') { + filters.includeExpiredPlans = true; + } + const results = await Group.getGroups({ user, types, diff --git a/website/server/libs/inbox/index.js b/website/server/libs/inbox/index.js index af0d7ae07d..c28400c583 100644 --- a/website/server/libs/inbox/index.js +++ b/website/server/libs/inbox/index.js @@ -1,6 +1,6 @@ import { mapInboxMessage, inboxModel } from '../../models/message'; import { getUserInfo, sendTxn as sendTxnEmail } from '../email'; // eslint-disable-line import/no-cycle -import { sendNotification as sendPushNotification } from '../pushNotifications'; +import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle export async function sentMessage (sender, receiver, message, translate) { const fakeSending = sender.flags.chatShadowMuted; diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 93ff285b41..9a0dd3126c 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -153,6 +153,10 @@ async function prepareSubscriptionValues (data) { groupId = group._id; recipient.purchased.plan.quantity = data.sub.quantity; + if (group.type === 'party') { + await group.removeGroupInvitations(); + } + await addSubscriptionToGroupUsers(group); } diff --git a/website/server/models/group.js b/website/server/models/group.js index 36c678c2cd..f22d378652 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -156,7 +156,16 @@ schema.plugin(baseModel, { noSet: ['_id', 'balance', 'quest', 'memberCount', 'chat', 'bannedWordsAllowed', 'challengeCount', 'tasksOrder', 'purchased', 'managers'], private: ['purchased.plan'], toJSONTransform (plainObj, originalDoc) { - if (plainObj.purchased) plainObj.purchased.active = originalDoc.hasActiveGroupPlan(); + if (plainObj.purchased) { + plainObj.purchased.active = originalDoc.hasActiveGroupPlan(); + const plan = originalDoc.purchased && originalDoc.purchased.plan; + if (plan && plan.customerId) { + plainObj.purchased.wasUpgraded = true; + if (plan.dateTerminated) { + plainObj.purchased.dateTerminated = plan.dateTerminated; + } + } + } }, }); @@ -309,13 +318,16 @@ schema.statics.getGroups = async function getGroups (options = {}) { privacy: 'private', _id: { $in: user.guilds }, 'purchased.plan.customerId': { $exists: true }, - $or: [ + }; + if (!filters.includeExpiredPlans) { + query.$or = [ { 'purchased.plan.dateTerminated': null }, { 'purchased.plan.dateTerminated': { $exists: false } }, { 'purchased.plan.dateTerminated': { $gt: new Date() } }, - ], - }; - _.assign(query, filters); + ]; + } + const filtersWithoutCustom = _.omit(filters, ['includeExpiredPlans']); + _.assign(query, filtersWithoutCustom); const privateGuildsQuery = this.find(query).select(groupFields); if (populateLeader === true) privateGuildsQuery.populate('leader', nameFields); privateGuildsQuery.sort(sort);