From d3d39a81d6614d016663a710338d177063fc49de Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 23 Jan 2025 12:53:28 +0530
Subject: [PATCH 1/3] fix: Prevent duplicate chat creation in the web widget
during latency (#10745)
---
app/javascript/shared/components/Button.vue | 3 +-
.../widget/components/PreChat/Form.vue | 8 +++--
app/javascript/widget/router.js | 35 ++++++++++++++++++-
.../widget/store/modules/appConfig.js | 13 +++++++
.../modules/specs/appConfig/actions.spec.js | 7 ++++
.../modules/specs/appConfig/getters.spec.js | 6 ++++
.../modules/specs/appConfig/mutations.spec.js | 8 +++++
.../specs/conversation/actions.spec.js | 1 +
app/javascript/widget/store/types.js | 1 +
app/javascript/widget/views/PreChatForm.vue | 16 ++++++---
10 files changed, 90 insertions(+), 8 deletions(-)
diff --git a/app/javascript/shared/components/Button.vue b/app/javascript/shared/components/Button.vue
index e49c380e0..5d4213d0b 100644
--- a/app/javascript/shared/components/Button.vue
+++ b/app/javascript/shared/components/Button.vue
@@ -24,7 +24,8 @@ export default {
},
computed: {
buttonClassName() {
- let className = 'text-white py-3 px-4 rounded shadow-sm leading-4';
+ let className =
+ 'text-white py-3 px-4 rounded shadow-sm leading-4 cursor-pointer disabled:opacity-50';
if (this.type === 'clear') {
className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600';
}
diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue
index 6b2c93cd6..280cfbf43 100644
--- a/app/javascript/widget/components/PreChat/Form.vue
+++ b/app/javascript/widget/components/PreChat/Form.vue
@@ -52,9 +52,13 @@ export default {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isCreating: 'conversation/getIsCreating',
+ isConversationRouting: 'appConfig/getIsUpdatingRoute',
activeCampaign: 'campaign/getActiveCampaign',
currentUser: 'contacts/getCurrentUser',
}),
+ isCreatingConversation() {
+ return this.isCreating || this.isConversationRouting;
+ },
textColor() {
return getContrastingTextColor(this.widgetColor);
},
@@ -337,9 +341,9 @@ export default {
block
:bg-color="widgetColor"
:text-color="textColor"
- :disabled="isCreating"
+ :disabled="isCreatingConversation"
>
-
+
{{ $t('START_CONVERSATION') }}
diff --git a/app/javascript/widget/router.js b/app/javascript/widget/router.js
index 418fd9ae6..e8230c2d9 100755
--- a/app/javascript/widget/router.js
+++ b/app/javascript/widget/router.js
@@ -1,7 +1,8 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
+import store from './store';
-export default createRouter({
+const router = createRouter({
history: createWebHashHistory(),
routes: [
{
@@ -42,3 +43,35 @@ export default createRouter({
},
],
});
+
+/**
+ * Navigation Guards to Handle Route Transitions
+ *
+ * Purpose:
+ * Prevents duplicate form submissions and API calls during route transitions,
+ * especially important in high-latency scenarios.
+ *
+ * Flow:
+ * 1. beforeEach: Sets isUpdatingRoute to true at start of navigation
+ * 2. Component buttons/actions check this flag to prevent duplicate actions
+ * 3. afterEach: Resets the flag once navigation is complete
+ *
+ * Implementation note:
+ * Handling it globally, so that we can use it across all components
+ * to ensure consistent UI behavior during all route transitions.
+ *
+ * @see https://github.com/chatwoot/chatwoot/issues/10736
+ */
+
+router.beforeEach(async (to, from, next) => {
+ // Prevent any user interactions during route transition
+ await store.dispatch('appConfig/setRouteTransitionState', true);
+ next();
+});
+
+router.afterEach(() => {
+ // Re-enable user interactions after navigation is complete
+ store.dispatch('appConfig/setRouteTransitionState', false);
+});
+
+export default router;
diff --git a/app/javascript/widget/store/modules/appConfig.js b/app/javascript/widget/store/modules/appConfig.js
index a7df60d29..44a13c4c4 100644
--- a/app/javascript/widget/store/modules/appConfig.js
+++ b/app/javascript/widget/store/modules/appConfig.js
@@ -5,6 +5,7 @@ import {
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
TOGGLE_WIDGET_OPEN,
+ SET_ROUTE_UPDATE_STATE,
} from '../types';
const state = {
@@ -19,6 +20,7 @@ const state = {
widgetColor: '',
widgetStyle: 'standard',
darkMode: 'light',
+ isUpdatingRoute: false,
};
export const getters = {
@@ -31,6 +33,7 @@ export const getters = {
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
+ getIsUpdatingRoute: _state => _state.isUpdatingRoute,
};
export const actions = {
@@ -69,6 +72,13 @@ export const actions = {
setBubbleVisibility({ commit }, hideMessageBubble) {
commit(SET_BUBBLE_VISIBILITY, hideMessageBubble);
},
+ setRouteTransitionState: async ({ commit }, status) => {
+ // Handles the routing state during navigation to different screen
+ // Called before the navigation starts and after navigation completes
+ // Handling this state in app/javascript/widget/router.js
+ // See issue: https://github.com/chatwoot/chatwoot/issues/10736
+ commit(SET_ROUTE_UPDATE_STATE, status);
+ },
};
export const mutations = {
@@ -96,6 +106,9 @@ export const mutations = {
[SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode;
},
+ [SET_ROUTE_UPDATE_STATE]($state, status) {
+ $state.isUpdatingRoute = status;
+ },
};
export default {
diff --git a/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js b/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
index 4c66600f2..a99079562 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/actions.spec.js
@@ -31,4 +31,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
});
});
+
+ describe('#setRouteTransitionState', () => {
+ it('creates actions properly', () => {
+ actions.setRouteTransitionState({ commit }, false);
+ expect(commit.mock.calls).toEqual([['SET_ROUTE_UPDATE_STATE', false]]);
+ });
+ });
});
diff --git a/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js b/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
index ab0c6e6ca..5d3db77bc 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
@@ -19,4 +19,10 @@ describe('#getters', () => {
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
+ describe('#getIsUpdatingRoute', () => {
+ it('returns correct value', () => {
+ const state = { isUpdatingRoute: true };
+ expect(getters.getIsUpdatingRoute(state)).toEqual(true);
+ });
+ });
});
diff --git a/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js b/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
index e79235e28..e25fea368 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/mutations.spec.js
@@ -32,4 +32,12 @@ describe('#mutations', () => {
expect(state.darkMode).toEqual('dark');
});
});
+
+ describe('#SET_ROUTE_UPDATE_STATE', () => {
+ it('sets dark mode properly', () => {
+ const state = { isUpdatingRoute: false };
+ mutations.SET_ROUTE_UPDATE_STATE(state, true);
+ expect(state.isUpdatingRoute).toEqual(true);
+ });
+ });
});
diff --git a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
index dbf6f692b..39b8afe1a 100644
--- a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
+++ b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
@@ -17,6 +17,7 @@ describe('#actions', () => {
messages: [{ id: 1, content: 'This is a test message' }],
},
});
+
let windowSpy = vi.spyOn(window, 'window', 'get');
windowSpy.mockImplementation(() => ({
WOOT_WIDGET: {
diff --git a/app/javascript/widget/store/types.js b/app/javascript/widget/store/types.js
index 17398b682..b4c2a968f 100644
--- a/app/javascript/widget/store/types.js
+++ b/app/javascript/widget/store/types.js
@@ -7,3 +7,4 @@ export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
export const SET_BUBBLE_VISIBILITY = 'SET_BUBBLE_VISIBILITY';
+export const SET_ROUTE_UPDATE_STATE = 'SET_ROUTE_UPDATE_STATE';
diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue
index d47e03736..8eac55808 100644
--- a/app/javascript/widget/views/PreChatForm.vue
+++ b/app/javascript/widget/views/PreChatForm.vue
@@ -12,12 +12,20 @@ export default {
},
mixins: [configMixin, routerMixin],
mounted() {
- emitter.on(ON_CONVERSATION_CREATED, () => {
- // Redirect to messages page after conversation is created
- this.replaceRoute('messages');
- });
+ // Register event listener for conversation creation
+ emitter.on(ON_CONVERSATION_CREATED, this.handleConversationCreated);
+ },
+ beforeUnmount() {
+ emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
},
methods: {
+ handleConversationCreated() {
+ // Redirect to messages page after conversation is created
+ this.replaceRoute('messages');
+ // Only after successful navigation, reset the isUpdatingRoute UIflag in app/javascript/widget/router.js
+ // See issue: https://github.com/chatwoot/chatwoot/issues/10736
+ },
+
onSubmit({
fullName,
emailAddress,
From b429ce0ad588b4f74efa055807a262fd455e118e Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Thu, 23 Jan 2025 18:18:14 +0530
Subject: [PATCH 2/3] fix: Prevent template variables from becoming links
(#10725)
# Pull Request Template
## Description
**Issue**
This PR fixes template variables in messages (e.g., {{customer.name}})
that were being incorrectly converted to clickable links by the
`MessageFormatter's linkify` functionality. This caused formatting
issues and broken links.
**Solution**
Added a `linkify` parameter to `MessageFormatter` to optionally disable
link conversion
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screenshots**
**Before**
**After**
## 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
- [x] 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
---
.../Campaigns/CampaignCard/CampaignCard.vue | 2 +-
.../specs/useMessageFormatter.spec.js | 12 +++++
.../shared/composables/useMessageFormatter.js | 6 ++-
.../shared/helpers/MessageFormatter.js | 52 +++++++++++--------
.../helpers/specs/MessageFormatter.spec.js | 7 +++
5 files changed, 55 insertions(+), 24 deletions(-)
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
index 229553253..304e55347 100644
--- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
diff --git a/app/javascript/shared/composables/specs/useMessageFormatter.spec.js b/app/javascript/shared/composables/specs/useMessageFormatter.spec.js
index e9e3962d7..8021fc50d 100644
--- a/app/javascript/shared/composables/specs/useMessageFormatter.spec.js
+++ b/app/javascript/shared/composables/specs/useMessageFormatter.spec.js
@@ -30,6 +30,18 @@ describe('useMessageFormatter', () => {
'
{
+ const message = 'Check https://example.com and {{user.id}}';
+ const result = messageFormatter.formatMessage(
+ message,
+ false,
+ false,
+ false
+ );
+ expect(result).not.toContain(' {
diff --git a/app/javascript/shared/composables/useMessageFormatter.js b/app/javascript/shared/composables/useMessageFormatter.js
index 16b7f25a6..974d113fd 100644
--- a/app/javascript/shared/composables/useMessageFormatter.js
+++ b/app/javascript/shared/composables/useMessageFormatter.js
@@ -14,11 +14,13 @@ export const useMessageFormatter = () => {
* @param {boolean} isAPrivateNote - Whether the message is a private note.
* @returns {string} - The formatted message.
*/
- const formatMessage = (message, isATweet, isAPrivateNote) => {
+ // TODO: ref:https://github.com/chatwoot/chatwoot/pull/10725#discussion_r1925300874
+ const formatMessage = (message, isATweet, isAPrivateNote, linkify) => {
const messageFormatter = new MessageFormatter(
message,
isATweet,
- isAPrivateNote
+ isAPrivateNote,
+ linkify
);
return messageFormatter.formattedMessage;
};
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index 06bd8bfae..c87209fd4 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -1,6 +1,7 @@
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
import MarkdownIt from 'markdown-it';
+
const setImageHeight = inlineToken => {
const imgSrc = inlineToken.attrGet('src');
if (!imgSrc) return;
@@ -30,25 +31,27 @@ const imgResizeManager = md => {
});
};
-const md = MarkdownIt({
- html: false,
- xhtmlOut: true,
- breaks: true,
- langPrefix: 'language-',
- linkify: true,
- typographer: true,
- quotes: '\u201c\u201d\u2018\u2019',
- maxNesting: 20,
-})
- .use(mentionPlugin)
- .use(imgResizeManager)
- .use(mila, {
- attrs: {
- class: 'link',
- rel: 'noreferrer noopener nofollow',
- target: '_blank',
- },
- });
+const createMarkdownInstance = (linkify = true) => {
+ return MarkdownIt({
+ html: false,
+ xhtmlOut: true,
+ breaks: true,
+ langPrefix: 'language-',
+ linkify,
+ typographer: true,
+ quotes: '\u201c\u201d\u2018\u2019',
+ maxNesting: 20,
+ })
+ .use(mentionPlugin)
+ .use(imgResizeManager)
+ .use(mila, {
+ attrs: {
+ class: 'link',
+ rel: 'noreferrer noopener nofollow',
+ target: '_blank',
+ },
+ });
+};
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
@@ -56,10 +59,17 @@ const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
class MessageFormatter {
- constructor(message, isATweet = false, isAPrivateNote = false) {
+ constructor(
+ message,
+ isATweet = false,
+ isAPrivateNote = false,
+ linkify = true
+ ) {
this.message = message || '';
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
+ this.linkify = linkify;
+ this.md = createMarkdownInstance(linkify);
}
formatMessage() {
@@ -74,7 +84,7 @@ class MessageFormatter {
TWITTER_HASH_REPLACEMENT
);
}
- return md.render(updatedMessage);
+ return this.md.render(updatedMessage);
}
get formattedMessage() {
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 47760f7a8..a685cb0da 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -16,6 +16,13 @@ describe('#MessageFormatter', () => {
'Chatwoot is an opensource tool. https://www.chatwoot.com
'
);
});
+ it('should not convert template variables to links when linkify is disabled', () => {
+ const message = 'Hey {{customer.name}}, check https://chatwoot.com';
+ const formatter = new MessageFormatter(message, false, false, false);
+ expect(formatter.formattedMessage).toMatch(
+ 'Hey {{customer.name}}, check https://chatwoot.com
'
+ );
+ });
});
describe('parses heading to strong', () => {
From ef7bf66476173588d7dff64c88aba4ac5bf40c81 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Fri, 24 Jan 2025 22:51:09 +0530
Subject: [PATCH 3/3] feat: Add frontend changes for Captain limits (#10749)
This PR introduces several improvements to the Captain AI dashboard
section:
- New billing page, with new colors, layout and meters for Captain usage
- Updated the base paywall component to use new colors
- Updated PageLayout.vue, it's more generic and can be used for other
pages as well
- Use flags to toggle empty state and loading state
- Add prop for `featureFlag` to show the paywall slot based on feature
enabled on account
- Update `useAccount` to add a `isCloudFeatureEnabled`
- **Removed feature flag checks from captain route definitions**, so the
captain entry will always be visible on the sidebar
- Add banner to Captain pages for the following cases
- Responses usage is over 80%
- Documents limit is fully exhausted
### Screenshots
Free plan


Paid plan


---------
Co-authored-by: Sojan Jose
Co-authored-by: Pranav
Co-authored-by: Muhsin Keloth
---
.../components-next/EmptyStateLayout.vue | 12 +-
.../components-next/banner/Banner.vue | 73 +++++
.../components-next/captain/PageLayout.vue | 60 +++-
.../captain/assistant/AssistantCard.vue | 51 ++--
.../captain/assistant/DocumentCard.vue | 37 ++-
.../captain/assistant/InboxCard.vue | 6 +-
.../captain/assistant/ResponseCard.vue | 6 +-
.../captain/pageComponents/Paywall.vue | 41 +++
.../pageComponents/document/LimitBanner.vue | 40 +++
.../emptyStates/AssistantPageEmptyState.vue | 1 +
.../emptyStates/DocumentPageEmptyState.vue | 1 +
.../emptyStates/InboxPageEmptyState.vue | 1 +
.../emptyStates/ResponsePageEmptyState.vue | 1 +
.../pageComponents/response/LimitBanner.vue | 42 +++
.../components-next/sidebar/Sidebar.vue | 5 +
.../components-next/sidebar/SidebarGroup.vue | 2 +
.../sidebar/SidebarGroupLeaf.vue | 12 +-
.../sidebar/SidebarSubGroup.vue | 7 +-
.../components-next/sidebar/provider.js | 4 +
.../dashboard/composables/useAccount.js | 12 +-
.../dashboard/composables/useCaptain.js | 46 +++
.../dashboard/helper/featureHelper.js | 1 +
.../i18n/locale/en/integrations.json | 28 +-
.../dashboard/i18n/locale/en/settings.json | 16 +-
.../dashboard/captain/assistants/Index.vue | 50 +--
.../captain/assistants/inboxes/Index.vue | 46 ++-
.../dashboard/captain/captain.routes.js | 6 +-
.../dashboard/captain/documents/Index.vue | 65 ++--
.../dashboard/captain/responses/Index.vue | 106 ++++---
.../dashboard/settings/SettingsLayout.vue | 2 +-
.../dashboard/settings/billing/Index.vue | 288 +++++++++++-------
.../settings/billing/billing.routes.js | 4 +-
.../billing/components/BillingCard.vue | 25 ++
.../billing/components/BillingHeader.vue | 26 ++
.../billing/components/BillingItem.vue | 40 ---
.../billing/components/BillingMeter.vue | 45 +++
.../billing/components/DetailItem.vue | 23 ++
.../settings/components/BasePaywallModal.vue | 41 +--
.../enterprise/api/v1/accounts_controller.rb | 3 +-
.../billing/handle_stripe_event_service.rb | 2 +-
.../api/v1/accounts_controller_spec.rb | 12 +
41 files changed, 920 insertions(+), 369 deletions(-)
create mode 100644 app/javascript/dashboard/components-next/banner/Banner.vue
create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/Paywall.vue
create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/LimitBanner.vue
create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/response/LimitBanner.vue
create mode 100644 app/javascript/dashboard/composables/useCaptain.js
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingCard.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingHeader.vue
delete mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingMeter.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/billing/components/DetailItem.vue
diff --git a/app/javascript/dashboard/components-next/EmptyStateLayout.vue b/app/javascript/dashboard/components-next/EmptyStateLayout.vue
index 04c478ba6..686d4b8a4 100644
--- a/app/javascript/dashboard/components-next/EmptyStateLayout.vue
+++ b/app/javascript/dashboard/components-next/EmptyStateLayout.vue
@@ -1,4 +1,6 @@
@@ -16,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
diff --git a/app/javascript/dashboard/components-next/banner/Banner.vue b/app/javascript/dashboard/components-next/banner/Banner.vue
new file mode 100644
index 000000000..cc5af3272
--- /dev/null
+++ b/app/javascript/dashboard/components-next/banner/Banner.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+ {{ actionLabel }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/PageLayout.vue b/app/javascript/dashboard/components-next/captain/PageLayout.vue
index b0d10b889..1ce03cec5 100644
--- a/app/javascript/dashboard/components-next/captain/PageLayout.vue
+++ b/app/javascript/dashboard/components-next/captain/PageLayout.vue
@@ -1,8 +1,12 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/LimitBanner.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/LimitBanner.vue
new file mode 100644
index 000000000..d4f512f39
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/document/LimitBanner.vue
@@ -0,0 +1,40 @@
+
+
+
+
+ {{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue
index 0ae292eb4..a12fcce74 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue
@@ -15,6 +15,7 @@ const onClick = () => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
index 614a82a88..23ad01926 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
@@ -15,6 +15,7 @@ const onClick = () => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue
index e13413a07..2a0735930 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue
@@ -15,6 +15,7 @@ const onClick = () => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue
index 2582aa6af..2b8be652e 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue
@@ -15,6 +15,7 @@ const onClick = () => {
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/response/LimitBanner.vue b/app/javascript/dashboard/components-next/captain/pageComponents/response/LimitBanner.vue
new file mode 100644
index 000000000..0b5462f0b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/response/LimitBanner.vue
@@ -0,0 +1,42 @@
+
+
+
+
+ {{ $t('CAPTAIN.BANNER.RESPONSES') }}
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 449ba9ada..3f2f4ed6f 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -171,20 +171,24 @@ const menuItems = computed(() => {
name: 'Captain',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
+ showOnlyOnCloud: true,
children: [
{
name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
+ showOnlyOnCloud: true,
to: accountScopedRoute('captain_assistants_index'),
},
{
name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
+ showOnlyOnCloud: true,
to: accountScopedRoute('captain_documents_index'),
},
{
name: 'Responses',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
+ showOnlyOnCloud: true,
to: accountScopedRoute('captain_responses_index'),
},
],
@@ -455,6 +459,7 @@ const menuItems = computed(() => {
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
+ showOnlyOnCloud: true,
to: accountScopedRoute('billing_settings_index'),
},
],
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue
index 36caf61fe..305171d10 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue
@@ -23,6 +23,7 @@ const {
resolvePath,
resolvePermissions,
resolveFeatureFlag,
+ isOnChatwootCloud,
isAllowed,
} = useSidebarContext();
@@ -41,6 +42,7 @@ const hasChildren = computed(
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
+ if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
// If a item has no link, it means it's just a subgroup header
// So we don't need to check for permissions here, because there's nothing to
// access here anyway
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
index 13bdb040d..a791953b6 100644
--- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -9,18 +9,28 @@ const props = defineProps({
to: { type: [String, Object], required: true },
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
+ showOnlyOnCloud: { type: Boolean, default: false },
component: { type: Function, default: null },
});
-const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
+const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
+ useSidebarContext();
+
+const allowedToShow = computed(() => {
+ if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
+
+ return true;
+});
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
});
+
- props.children.filter(child => isAllowed(child.to))
+ props.children.filter(child => {
+ if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
+ return child.to && isAllowed(child.to);
+ })
);
const hasAccessibleItems = computed(() => {
diff --git a/app/javascript/dashboard/components-next/sidebar/provider.js b/app/javascript/dashboard/components-next/sidebar/provider.js
index d6571dcd9..4d9973213 100644
--- a/app/javascript/dashboard/components-next/sidebar/provider.js
+++ b/app/javascript/dashboard/components-next/sidebar/provider.js
@@ -1,4 +1,5 @@
import { inject, provide } from 'vue';
+import { useMapGetter } from 'dashboard/composables/store';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
@@ -11,6 +12,8 @@ export function useSidebarContext() {
}
const router = useRouter();
+ const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
+
const { checkFeatureAllowed, checkPermissions } = usePolicy();
const resolvePath = to => {
@@ -41,6 +44,7 @@ export function useSidebarContext() {
resolvePermissions,
resolveFeatureFlag,
isAllowed,
+ isOnChatwootCloud,
};
}
diff --git a/app/javascript/dashboard/composables/useAccount.js b/app/javascript/dashboard/composables/useAccount.js
index c8c245adb..8ace2a5e1 100644
--- a/app/javascript/dashboard/composables/useAccount.js
+++ b/app/javascript/dashboard/composables/useAccount.js
@@ -13,10 +13,14 @@ export function useAccount() {
*/
const route = useRoute();
const getAccountFn = useMapGetter('accounts/getAccount');
+ const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
+ const isFeatureEnabledonAccount = useMapGetter(
+ 'accounts/isFeatureEnabledonAccount'
+ );
+
const accountId = computed(() => {
return Number(route.params.accountId);
});
-
const currentAccount = computed(() => getAccountFn.value(accountId.value));
/**
@@ -28,6 +32,10 @@ export function useAccount() {
return `/app/accounts/${accountId.value}/${url}`;
};
+ const isCloudFeatureEnabled = feature => {
+ return isFeatureEnabledonAccount.value(currentAccount.value.id, feature);
+ };
+
const accountScopedRoute = (name, params, query) => {
return {
name,
@@ -42,5 +50,7 @@ export function useAccount() {
currentAccount,
accountScopedUrl,
accountScopedRoute,
+ isCloudFeatureEnabled,
+ isOnChatwootCloud,
};
}
diff --git a/app/javascript/dashboard/composables/useCaptain.js b/app/javascript/dashboard/composables/useCaptain.js
new file mode 100644
index 000000000..d28560944
--- /dev/null
+++ b/app/javascript/dashboard/composables/useCaptain.js
@@ -0,0 +1,46 @@
+import { computed } from 'vue';
+import { useStore } from 'dashboard/composables/store.js';
+import { useAccount } from 'dashboard/composables/useAccount';
+import { useCamelCase } from 'dashboard/composables/useTransformKeys';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
+
+export function useCaptain() {
+ const store = useStore();
+ const { isCloudFeatureEnabled, currentAccount } = useAccount();
+
+ const captainEnabled = computed(() => {
+ return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
+ });
+
+ const captainLimits = computed(() => {
+ return currentAccount.value?.limits?.captain;
+ });
+
+ const documentLimits = computed(() => {
+ if (captainLimits.value?.documents) {
+ return useCamelCase(captainLimits.value.documents);
+ }
+
+ return null;
+ });
+
+ const responseLimits = computed(() => {
+ if (captainLimits.value?.responses) {
+ return useCamelCase(captainLimits.value.responses);
+ }
+
+ return null;
+ });
+
+ const fetchLimits = () => {
+ store.dispatch('accounts/limits');
+ };
+
+ return {
+ captainEnabled,
+ captainLimits,
+ documentLimits,
+ responseLimits,
+ fetchLimits,
+ };
+}
diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js
index 529c0a44e..ee61b0656 100644
--- a/app/javascript/dashboard/helper/featureHelper.js
+++ b/app/javascript/dashboard/helper/featureHelper.js
@@ -18,6 +18,7 @@ const FEATURE_HELP_URLS = {
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
+ billing: 'https://chwt.app/pricing',
};
export function getHelpUrlForFeature(featureName) {
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index dd76f0cdb..56f6e9776 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -309,6 +309,22 @@
"USE": "Use this",
"RESET": "Reset"
},
+ "PAYWALL": {
+ "TITLE": "Upgrade to use Captain AI",
+ "AVAILABLE_ON": "Captain is not available on the free plan.",
+ "UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
+ "UPGRADE_NOW": "Upgrade now",
+ "CANCEL_ANYTIME": "You can change or cancel your plan anytime"
+ },
+ "ENTERPRISE_PAYWALL": {
+ "AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
+ "UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
+ "ASK_ADMIN": "Please reach out to your administrator for the upgrade."
+ },
+ "BANNER": {
+ "RESPONSES": "You've used over 80% of your response limit. To continue using Captain AI, please upgrade.",
+ "DOCUMENTS": "Document limit reached. Upgrade to continue using Captain AI."
+ },
"FORM": {
"CANCEL": "Cancel",
"CREATE": "Create",
@@ -364,7 +380,7 @@
},
"EMPTY_STATE": {
"TITLE": "No assistants available",
- "SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations. Click the button below to get started."
+ "SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations."
}
},
"DOCUMENTS": {
@@ -406,13 +422,13 @@
},
"EMPTY_STATE": {
"TITLE": "No documents available",
- "SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant. Click the button below to get started."
+ "SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant."
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
- "DOCUMENTABLE" : {
+ "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"DELETE": {
@@ -422,7 +438,7 @@
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
},
- "FILTER" :{
+ "FILTER": {
"ASSISTANT": "Assistant: {selected}",
"STATUS": "Status: {selected}",
"ALL_ASSISTANTS": "All"
@@ -470,7 +486,7 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
- "SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually. Click the button below to create your first FAQ."
+ "SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually."
}
},
"INBOXES": {
@@ -501,7 +517,7 @@
},
"EMPTY_STATE": {
"TITLE": "No Connected Inboxes",
- "SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you. Click the button below to set it up now."
+ "SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you."
}
}
}
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index e532f8e8f..9f5ad31c1 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -265,7 +265,7 @@
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
- "CAPTAIN_RESPONSES" : "FAQs",
+ "CAPTAIN_RESPONSES": "FAQs",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -327,15 +327,27 @@
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
+ "DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
- "PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses"
+ "PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
+ "SEAT_COUNT": "Number of seats",
+ "RENEWS_ON": "Renews on"
},
+ "VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
"TITLE": "Manage your subscription",
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
+ "CAPTAIN": {
+ "TITLE": "Captain",
+ "DESCRIPTION": "Manage usage and credits for Captain AI.",
+ "BUTTON_TXT": "Buy more credits",
+ "DOCUMENTS": "Documents",
+ "RESPONSES": "Responses",
+ "UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
+ },
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
index 6bba32eb2..3ea78e74a 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
@@ -1,13 +1,15 @@
-
-
-
-
+
@@ -89,23 +86,22 @@ onMounted(() =>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
-import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
+import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
+import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
const store = useStore();
@@ -103,38 +105,49 @@ onMounted(() => {
-
-
-
-
-
-
-
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
-
-
+
+
{{ noRecordsMessage }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
index cd0b15f4e..8f829ed0c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
@@ -1,123 +1,181 @@
-
-
-
-
-
{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}
-
-
-
-
{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}
-
-
-
+
+
-
-
-
+
+
+
+
+
+
+ {{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
+
+
+
+
+
+
+ {{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
+
+
+
+
+
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js
index 9fc09a00a..26b441c75 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js
@@ -1,5 +1,5 @@
import { frontendURL } from '../../../../helper/URLHelper';
-import SettingsContent from '../Wrapper.vue';
+import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
@@ -9,7 +9,7 @@ export default {
meta: {
permissions: ['administrator'],
},
- component: SettingsContent,
+ component: SettingsWrapper,
props: {
headerTitle: 'BILLING_SETTINGS.TITLE',
icon: 'credit-card-person',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingCard.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingCard.vue
new file mode 100644
index 000000000..d9179b76c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingCard.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingHeader.vue
new file mode 100644
index 000000000..ef0b8d9cd
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingHeader.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ description }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue
deleted file mode 100644
index a63744655..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
{{ title }}
-
- {{ description }}
-
-
-
-
- {{ buttonLabel }}
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingMeter.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingMeter.vue
new file mode 100644
index 000000000..fb057b44a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingMeter.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {{ title }}
+
+
{{ consumed }} / {{ totalCount }}
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/DetailItem.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/DetailItem.vue
new file mode 100644
index 000000000..1c4f2c458
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/DetailItem.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+ {{ label }}
+
+
+ {{ value }}
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue
index 36a6b3b8e..e2b552d52 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/components/BasePaywallModal.vue
@@ -1,4 +1,7 @@