feat: Upgrade page instead of banner (#11202)

# Pull Request Template

## Description

This PR will replace the upgrade banner with an upgrade page view.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/0f2b4b09acdd4404bf3211184a470227?sid=7ed60a99-0299-4642-b907-2af8c4dcc643


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Sivin Varghese
2025-03-28 14:58:17 +05:30
committed by GitHub
parent 0175714d65
commit 4e58a2a91d
8 changed files with 255 additions and 48 deletions

View File

@@ -18,4 +18,8 @@ module BillingHelper
def non_web_inboxes(account)
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
end
def agents(account)
account.users.count
end
end

View File

@@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -31,7 +30,6 @@ export default {
UpdateBanner,
PaymentPendingBanner,
WootSnackbarBox,
UpgradeBanner,
PendingEmailVerificationBanner,
},
setup() {
@@ -146,7 +144,6 @@ export default {
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">

View File

@@ -1,5 +1,11 @@
{
"GENERAL_SETTINGS": {
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
"SUBMIT": "Update settings",
"BACK": "Back",
@@ -51,6 +57,7 @@
"UPDATE_CHATWOOT": "An update {latestChatwootVersion} for Chatwoot is available. Please update your instance.",
"LEARN_MORE": "Learn more",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
"UPGRADE": "Upgrade to continue using Chatwoot",
"LIMITS_UPGRADE": "Your account has exceeded the usage limits, please upgrade your plan to continue using Chatwoot",
"OPEN_BILLING": "Open billing"
},

View File

@@ -1,6 +1,6 @@
<script>
import { defineAsyncComponent, ref } from 'vue';
import { mapGetters } from 'vuex';
import { defineAsyncComponent } from 'vue';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
@@ -8,6 +8,7 @@ import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAc
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -35,8 +36,10 @@ export default {
AccountSelector,
AddLabelModal,
NotificationPanel,
UpgradePage,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
@@ -44,6 +47,7 @@ export default {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
};
},
data() {
@@ -64,6 +68,16 @@ export default {
currentRoute() {
return ' ';
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
bypassUpgradePage() {
return [
'billing_settings_index',
'settings_inbox_list',
'agent_list',
].includes(this.$route.name);
},
isSidebarOpen() {
const { show_secondary_sidebar: showSecondarySidebar } = this.uiSettings;
return showSecondarySidebar;
@@ -197,8 +211,25 @@ export default {
@show-add-label-popup="showAddLabelPopup"
/>
<main class="flex flex-1 h-full min-h-0 px-0 overflow-hidden">
<router-view />
<CommandBar />
<UpgradePage
v-show="showUpgradePage"
ref="upgradePageRef"
:bypass-upgrade-page="bypassUpgradePage"
/>
<template v-if="!showUpgradePage">
<router-view />
<CommandBar />
<NotificationPanel
v-if="isNotificationPanel"
@close="closeNotificationPanel"
/>
<woot-modal
v-model:show="showAddLabelModal"
:on-close="hideAddLabelPopup"
>
<AddLabelModal @close="hideAddLabelPopup" />
</woot-modal>
</template>
<AccountSelector
:show-account-modal="showAccountModal"
@close-account-modal="toggleAccountModal"
@@ -213,16 +244,6 @@ export default {
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<NotificationPanel
v-if="isNotificationPanel"
@close="closeNotificationPanel"
/>
<woot-modal
v-model:show="showAddLabelModal"
:on-close="hideAddLabelPopup"
>
<AddLabelModal @close="hideAddLabelPopup" />
</woot-modal>
</main>
</div>
</template>

View File

@@ -0,0 +1,145 @@
<script setup>
import { onMounted, computed, defineExpose, defineProps } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
bypassUpgradePage: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const testLimit = ({ allowed, consumed }) => {
return consumed > allowed;
};
const isTrialAccount = computed(() => {
// check if account is less than 15 days old
const account = currentAccount.value;
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
});
const limitExceededMessage = computed(() => {
const account = currentAccount.value;
if (!account?.limits) return '';
const {
conversation,
non_web_inboxes: nonWebInboxes,
agents,
} = account.limits;
let message = '';
if (testLimit(conversation)) {
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.CONVERSATION');
} else if (testLimit(nonWebInboxes)) {
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.INBOXES');
} else if (testLimit(agents)) {
message = t('GENERAL_SETTINGS.LIMIT_MESSAGES.AGENTS');
}
return message;
});
const isLimitExceeded = computed(() => {
const account = currentAccount.value;
if (!account?.limits) return false;
const {
conversation,
non_web_inboxes: nonWebInboxes,
agents,
} = account.limits;
return (
testLimit(conversation) || testLimit(nonWebInboxes) || testLimit(agents)
);
});
const shouldShowUpgradePage = computed(() => {
// Skip upgrade page in Billing, Inbox, and Agent pages
if (props.bypassUpgradePage) return false;
if (!isOnChatwootCloud.value) return false;
if (isTrialAccount.value) return false;
return isLimitExceeded.value;
});
const fetchLimits = () => {
store.dispatch('accounts/limits');
};
const routeToBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
onMounted(() => fetchLimits());
defineExpose({ shouldShowUpgradePage });
</script>
<template>
<template v-if="shouldShowUpgradePage">
<div class="mx-auto h-full pt-[clamp(3rem,15vh,12rem)]">
<div
class="flex flex-col gap-4 max-w-md px-8 py-6 shadow-lg bg-n-solid-1 rounded-xl outline outline-1 outline-n-container"
>
<div class="flex flex-col gap-4">
<div class="flex items-center w-full gap-2">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.UPGRADE') }}
</span>
</div>
<div>
<p class="text-sm font-normal text-n-slate-11 mb-3">
{{ limitExceededMessage }}
</p>
<p v-if="!isAdmin">
{{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</p>
</div>
</div>
<NextButton
v-if="isAdmin"
:label="$t('GENERAL_SETTINGS.OPEN_BILLING')"
icon="i-lucide-credit-card"
@click="routeToBilling()"
/>
</div>
</div>
</template>
<template v-else />
</template>

View File

@@ -8,7 +8,7 @@ class AccountPolicy < ApplicationPolicy
end
def limits?
@account_user.administrator?
@account_user.administrator? || @account_user.agent?
end
def update?

View File

@@ -13,24 +13,24 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
def limits
limits = {
'conversation' => {},
'non_web_inboxes' => {},
'captain' => @account.usage_limits[:captain]
}
if default_plan?(@account)
limits = {
'conversation' => {
'allowed' => 500,
'consumed' => conversations_this_month(@account)
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => non_web_inboxes(@account)
}
}
end
limits = if default_plan?(@account)
{
'conversation' => {
'allowed' => 500,
'consumed' => conversations_this_month(@account)
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => non_web_inboxes(@account)
},
'agents' => {
'allowed' => 2,
'consumed' => agents(@account)
}
}
else
default_limits
end
# include id in response to ensure that the store can be updated on the frontend
render json: { id: @account.id, limits: limits }, status: :ok
@@ -49,6 +49,15 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
private
def default_limits
{
'conversation' => {},
'non_web_inboxes' => {},
'agents' => {},
'captain' => @account.usage_limits[:captain]
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)

View File

@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe 'Enterprise Billing APIs', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do
@@ -121,13 +121,36 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
end
context 'when it is an authenticated user' do
before do
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
end
context 'when it is an agent' do
it 'returns unauthorized' do
get "/enterprise/api/v1/accounts/#{account.id}/limits",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).to eq(account.id)
expect(json_response['limits']).to eq(
{
'conversation' => {
'allowed' => 500,
'consumed' => 0
},
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => 0
},
'agents' => {
'allowed' => 2,
'consumed' => 2
}
}
)
end
end
@@ -155,6 +178,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => 1
},
'agents' => {
'allowed' => 2,
'consumed' => 2
}
}
}
@@ -172,18 +199,11 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
expected_response = {
'id' => account.id,
'limits' => {
'agents' => {},
'conversation' => {},
'captain' => {
'documents' => {
'consumed' => 0,
'current_available' => ChatwootApp.max_limit,
'total_count' => ChatwootApp.max_limit
},
'responses' => {
'consumed' => 0,
'current_available' => ChatwootApp.max_limit,
'total_count' => ChatwootApp.max_limit
}
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
},
'non_web_inboxes' => {}
}
@@ -208,6 +228,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
'non_web_inboxes' => {
'allowed' => 0,
'consumed' => 1
},
'agents' => {
'allowed' => 2,
'consumed' => 2
}
}
}