diff --git a/.nvmrc b/.nvmrc
index 6f7af3750..b88575e38 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.5.1
\ No newline at end of file
+23.7.0
\ No newline at end of file
diff --git a/.rubocop.yml b/.rubocop.yml
index f5b8a2c1c..d87f08bfd 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -7,6 +7,7 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
+ - ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
@@ -41,6 +42,12 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
+Chatwoot/AttachmentDownload:
+ Enabled: true
+ Exclude:
+ - 'spec/**/*'
+ - 'test/**/*'
+
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -88,7 +95,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- - 'enterprise/app/helpers/captain/tool_execution_helper.rb'
+ - enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
diff --git a/Gemfile b/Gemfile
index d5e844382..1ae6cf093 100644
--- a/Gemfile
+++ b/Gemfile
@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
-gem 'stripe'
+gem 'stripe', '~> 18.0'
## - helper gems --##
## to populate db with sample data
@@ -191,9 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
-gem 'ai-agents', '>= 0.4.3'
+gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
+gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
@@ -214,7 +215,7 @@ group :production do
end
group :development do
- gem 'annotate'
+ gem 'annotaterb'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 7ce74a223..b42472ef4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -126,11 +126,11 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
- ai-agents (0.4.3)
- ruby_llm (~> 1.3)
- annotate (3.2.0)
- activerecord (>= 3.2, < 8.0)
- rake (>= 10.4, < 14.0)
+ ai-agents (0.7.0)
+ ruby_llm (~> 1.8.2)
+ annotaterb (4.20.0)
+ activerecord (>= 6.0.0)
+ activesupport (>= 6.0.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -819,7 +819,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
- ruby_llm (1.5.1)
+ ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -828,7 +828,7 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
- ruby_llm-schema (0.1.0)
+ ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -928,7 +928,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
- stripe (8.5.0)
+ stripe (18.0.1)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1017,8 +1017,8 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
- ai-agents (>= 0.4.3)
- annotate
+ ai-agents (>= 0.7.0)
+ annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
@@ -1119,6 +1119,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
+ ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1139,7 +1140,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
- stripe
+ stripe (~> 18.0)
telephone_number
test-prof
tidewave
diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb
index 4e7f2849d..8821da9d5 100644
--- a/app/builders/messages/messenger/message_builder.rb
+++ b/app/builders/messages/messenger/message_builder.rb
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
+ fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
+ fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
- if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
+ if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
+ # Handle different URL field names for different attachment types
+ url = case attachment['type'].to_sym
+ when :ig_story
+ attachment['payload']['story_media_url']
+ else
+ attachment['payload']['url']
+ end
+
{
- external_url: attachment['payload']['url'],
- remote_file_url: attachment['payload']['url']
+ external_url: url,
+ remote_file_url: url
}
end
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
message.save!
end
+ def fetch_ig_story_link(attachment)
+ message = attachment.message
+ # For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
+ message.content_attributes[:image_type] = 'ig_story'
+ message.content = I18n.t('conversations.messages.instagram_shared_story_content')
+ message.save!
+ end
+
+ def fetch_ig_post_link(attachment)
+ message = attachment.message
+ message.content_attributes[:image_type] = 'ig_post'
+ message.content = I18n.t('conversations.messages.instagram_shared_post_content')
+ message.save!
+ end
+
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
- [:template, :unsupported_type].include? attachment_type.to_sym
+ [:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
end
diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js
index 3f12dc007..9e6d40a62 100644
--- a/app/javascript/dashboard/api/enterprise/account.js
+++ b/app/javascript/dashboard/api/enterprise/account.js
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
+
+ createTopupCheckout(credits) {
+ return axios.post(`${this.url}topup_checkout`, { credits });
+ }
}
export default new EnterpriseAccountAPI();
diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js
index 9c65b0b67..47d2eb26d 100644
--- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js
+++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
+ expect(accountAPI).toHaveProperty('createTopupCheckout');
+ expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
+
+ it('#createTopupCheckout with credits', () => {
+ accountAPI.createTopupCheckout(1000);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/enterprise/api/v1/topup_checkout',
+ { credits: 1000 }
+ );
+ });
+
+ it('#createTopupCheckout with different credit amounts', () => {
+ const creditAmounts = [1000, 2500, 6000, 12000];
+ creditAmounts.forEach(credits => {
+ accountAPI.createTopupCheckout(credits);
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ '/enterprise/api/v1/topup_checkout',
+ { credits }
+ );
+ });
+ });
+
+ it('#getLimits', () => {
+ accountAPI.getLimits();
+ expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
+ });
});
});
diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue
index 67936fa59..ff1c616de 100644
--- a/app/javascript/dashboard/components-next/Editor/Editor.vue
+++ b/app/javascript/dashboard/components-next/Editor/Editor.vue
@@ -19,7 +19,6 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
- enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
@@ -102,7 +101,6 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
- :enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
@@ -139,19 +137,6 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
- @apply gap-2 !important;
-
- .ProseMirror-menubar {
- @apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
-
- .ProseMirror-menuitem {
- @apply h-5 !important;
- }
-
- .ProseMirror-icon {
- @apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
- }
- }
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
index 7beff200e..4c4d95f0c 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
- @apply p-0 mt-1 !mr-0;
+ @apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index aeca02a6c..b1852c6eb 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
return DyteBubble;
}
- if (props.contentAttributes.imageType === 'story_mention') {
+ const instagramSharedTypes = [
+ ATTACHMENT_TYPES.STORY_MENTION,
+ ATTACHMENT_TYPES.IG_STORY,
+ ATTACHMENT_TYPES.IG_POST,
+ ];
+ if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
return InstagramStoryBubble;
}
@@ -476,7 +481,7 @@ provideMessageContext({
+
+ line.trim() !== '');
- const filteredMarkdown = nonEmptyLines.join(' ');
- emitter.emit(
- BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
- `[${filteredMarkdown}](${url})`
- );
- } else {
- this.addIntoEditor(
- `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
- );
- }
+ // Removing empty lines from the title
+ const lines = title.split('\n');
+ const nonEmptyLines = lines.filter(line => line.trim() !== '');
+ const filteredMarkdown = nonEmptyLines.join(' ');
+ emitter.emit(
+ BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
+ `[${filteredMarkdown}](${url})`
+ );
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
- toggleRichContentEditor() {
- this.updateUISettings({
- display_rich_content_editor: !this.showRichContentEditor,
- });
-
- const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
-
- if (!this.showRichContentEditor && this.messageSignature) {
- // remove the old signature -> extract text from markdown -> attach new signature
- let message = removeSignature(this.message, this.messageSignature);
- message = extractTextFromMarkdown(message);
- message = appendSignature(message, plainTextSignature);
-
- this.message = message;
- } else {
- this.message = replaceSignature(
- this.message,
- plainTextSignature,
- this.messageSignature
- );
- }
- },
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -655,8 +565,8 @@ export default {
}
return this.sendWithSignature
- ? appendSignature(message, this.signatureToApply)
- : removeSignature(message, this.signatureToApply);
+ ? appendSignature(message, this.messageSignature)
+ : removeSignature(message, this.messageSignature);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -672,7 +582,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
- this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -715,9 +624,6 @@ export default {
},
onPaste(e) {
const data = e.clipboardData.files;
- if (!this.showRichContentEditor && data.length !== 0) {
- this.$refs.messageInput.$el.blur();
- }
if (!data.length || !data[0]) {
return;
}
@@ -851,7 +757,7 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
- message = appendSignature(message, this.signatureToApply);
+ message = appendSignature(message, this.messageSignature);
}
const updatedMessage = replaceVariablesInMessage({
@@ -875,40 +781,22 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
- if (this.showRichContentEditor) {
- if (this.isRecordingAudio) {
- this.toggleAudioRecorder();
- }
- return;
+ if (this.isRecordingAudio) {
+ this.toggleAudioRecorder();
}
- this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
- insertIntoTextEditor(text, selectionStart, selectionEnd) {
- const { message } = this;
- const newMessage =
- message.slice(0, selectionStart) +
- text +
- message.slice(selectionEnd, message.length);
- this.message = newMessage;
- },
addIntoEditor(content) {
- if (this.showRichContentEditor) {
- this.updateEditorSelectionWith = content;
- this.onFocus();
- }
- if (!this.showRichContentEditor) {
- const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
- this.insertIntoTextEditor(content, selectionStart, selectionEnd);
- }
+ this.updateEditorSelectionWith = content;
+ this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
- this.message = appendSignature(this.message, this.signatureToApply);
+ this.message = appendSignature(this.message, this.messageSignature);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -926,19 +814,15 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
- this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
- if (!this.isRecordingAudio) {
- return;
- }
- if (!this.isRecorderAudioStopped) {
- this.isRecorderAudioStopped = true;
+ if (!this.$refs.audioRecorderInput) return;
+ if (!this.recordingAudioState) {
this.$refs.audioRecorderInput.stopRecording();
- } else if (this.isRecorderAudioStopped) {
+ } else {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -947,9 +831,6 @@ export default {
this.toggleEmojiPicker();
}
},
- hideMentions() {
- this.showMentions = false;
- },
onTypingOn() {
this.toggleTyping('on');
},
@@ -1196,13 +1077,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
-
-
diff --git a/app/javascript/dashboard/composables/useCaptain.js b/app/javascript/dashboard/composables/useCaptain.js
index 3f93cfc58..a9eedcc9a 100644
--- a/app/javascript/dashboard/composables/useCaptain.js
+++ b/app/javascript/dashboard/composables/useCaptain.js
@@ -1,5 +1,5 @@
import { computed } from 'vue';
-import { useStore } from 'dashboard/composables/store.js';
+import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,6 +9,7 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
+ const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -34,6 +35,8 @@ export function useCaptain() {
return null;
});
+ const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
+
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -46,5 +49,6 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
+ isFetchingLimits,
};
}
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js
index 9a99516e8..70fda9540 100644
--- a/app/javascript/dashboard/constants/editor.js
+++ b/app/javascript/dashboard/constants/editor.js
@@ -1,23 +1,143 @@
-export const MESSAGE_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
- 'bulletList',
- 'orderedList',
- 'code',
-];
-
-export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
- 'imageUpload',
-];
+// Formatting rules for different contexts (channels and special contexts)
+// marks: inline formatting (strong, em, code, link, strike)
+// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
+export const FORMATTING = {
+ // Channel formatting
+ 'Channel::Email': {
+ marks: ['strong', 'em', 'code', 'link'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::WebWidget': {
+ marks: ['strong', 'em', 'code', 'link', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Api': {
+ marks: ['strong', 'em'],
+ nodes: [],
+ menu: ['strong', 'em', 'undo', 'redo'],
+ },
+ 'Channel::FacebookPage': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::TwitterProfile': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::TwilioSms': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::Sms': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ 'Channel::Whatsapp': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Line': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['codeBlock'],
+ menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
+ },
+ 'Channel::Telegram': {
+ marks: ['strong', 'em', 'link', 'code'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
+ },
+ 'Channel::Instagram': {
+ marks: ['strong', 'em', 'code', 'strike'],
+ nodes: ['bulletList', 'orderedList'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'bulletList',
+ 'orderedList',
+ 'strike',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Channel::Voice': {
+ marks: [],
+ nodes: [],
+ menu: [],
+ },
+ // Special contexts (not actual channels)
+ 'Context::Default': {
+ marks: ['strong', 'em', 'code', 'link', 'strike'],
+ nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+ menu: [
+ 'strong',
+ 'em',
+ 'code',
+ 'link',
+ 'strike',
+ 'bulletList',
+ 'orderedList',
+ 'undo',
+ 'redo',
+ ],
+ },
+ 'Context::MessageSignature': {
+ marks: ['strong', 'em', 'link'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
+ },
+ 'Context::InboxSettings': {
+ marks: ['strong', 'em', 'link'],
+ nodes: [],
+ menu: ['strong', 'em', 'link', 'undo', 'redo'],
+ },
+};
+// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -33,14 +153,81 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
-export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
+/**
+ * Markdown formatting patterns for stripping unsupported formatting.
+ *
+ * Maps camelCase type names to ProseMirror snake_case schema names.
+ * Order matters: codeBlock before code to avoid partial matches.
+ */
+export const MARKDOWN_PATTERNS = [
+ // --- BLOCK NODES ---
+ {
+ type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
+ patterns: [
+ { pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
+ ],
+ },
+ {
+ type: 'blockquote', // PM: blockquote, eg: > quote
+ patterns: [{ pattern: /^> ?/gm, replacement: '' }],
+ },
+ {
+ type: 'bulletList', // PM: bullet_list, eg: - item
+ patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
+ },
+ {
+ type: 'orderedList', // PM: ordered_list, eg: 1. item
+ patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
+ },
+ {
+ type: 'heading', // PM: heading, eg: ## Heading
+ patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
+ },
+ {
+ type: 'horizontalRule', // PM: horizontal_rule, eg: ---
+ patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
+ },
+ {
+ type: 'image', // PM: image, eg: 
+ patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
+ },
+ {
+ type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
+ patterns: [
+ { pattern: /\\\n/g, replacement: '\n' },
+ { pattern: / {2,}\n/g, replacement: '\n' },
+ ],
+ },
+ // --- INLINE MARKS ---
+ {
+ type: 'strong', // PM: strong, eg: **bold** or __bold__
+ patterns: [
+ { pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
+ { pattern: /__(.+?)__/g, replacement: '$1' },
+ ],
+ },
+ {
+ type: 'em', // PM: em, eg: *italic* or _italic_
+ patterns: [
+ { pattern: /(? [k, true]));
+ const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
+
+ // Process each formatting type in order (codeBlock before code is important!)
+ MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
+ // Check if this format type is supported by the schema
+ const isMarkSupported = supportedMarks.includes(type);
+ const isNodeSupported = supportedNodes.includes(type);
+
+ // If not supported, strip the formatting
+ if (!isMarkSupported && !isNodeSupported) {
+ patterns.forEach(({ pattern, replacement }) => {
+ sanitizedContent = sanitizedContent.replace(pattern, replacement);
+ });
+ }
+ });
+
+ return sanitizedContent;
+}
+
/**
* Content Node Creation Helper Functions for
* - mention
@@ -313,8 +356,17 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
- case 'cannedResponse':
- return new MessageMarkdownTransformer(messageSchema).parse(content);
+ case 'cannedResponse': {
+ // Strip unsupported formatting before parsing to ensure content can be inserted
+ // into channels that don't support certain markdown features (e.g., API channels)
+ const sanitizedContent = stripUnsupportedFormatting(
+ content,
+ state.schema
+ );
+ return new MessageMarkdownTransformer(state.schema).parse(
+ sanitizedContent
+ );
+ }
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +441,85 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
+
+/**
+ * Get the formatting configuration for a specific channel type.
+ * Returns the appropriate marks, nodes, and menu items for the editor.
+ *
+ * @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
+ * @returns {Object} The formatting configuration with marks, nodes, and menu properties
+ */
+export function getFormattingForEditor(channelType) {
+ return FORMATTING[channelType] || FORMATTING['Context::Default'];
+}
+
+/**
+ * Menu Positioning Helpers
+ * Handles floating menu bar positioning for text selection in the editor.
+ */
+
+const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
+
+/**
+ * Calculate selection coordinates with bias to handle line-wraps correctly.
+ * @param {EditorView} editorView - ProseMirror editor view
+ * @param {Selection} selection - Current text selection
+ * @param {DOMRect} rect - Container bounding rect
+ * @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
+ */
+export function getSelectionCoords(editorView, selection, rect) {
+ const start = editorView.coordsAtPos(selection.from, 1);
+ const end = editorView.coordsAtPos(selection.to, -1);
+
+ const selTop = Math.min(start.top, end.top);
+ const spaceAbove = selTop - rect.top;
+ const onTop =
+ spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
+
+ return { start, end, selTop, onTop };
+}
+
+/**
+ * Calculate anchor position based on selection visibility and RTL direction.
+ * @param {Object} coords - Selection coordinates from getSelectionCoords
+ * @param {DOMRect} rect - Container bounding rect
+ * @param {boolean} isRtl - Whether text direction is RTL
+ * @returns {number} Anchor x-position for menu
+ */
+export function getMenuAnchor(coords, rect, isRtl) {
+ const { start, end, onTop } = coords;
+
+ if (!onTop) return end.left;
+
+ // If start of selection is visible, align to text. Else stick to container edge.
+ if (start.top >= rect.top) return isRtl ? start.right : start.left;
+
+ return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
+}
+
+/**
+ * Calculate final menu position (left, top) within container bounds.
+ * @param {Object} coords - Selection coordinates from getSelectionCoords
+ * @param {DOMRect} rect - Container bounding rect
+ * @param {boolean} isRtl - Whether text direction is RTL
+ * @returns {{left: number, top: number, width: number}}
+ */
+export function calculateMenuPosition(coords, rect, isRtl) {
+ const { start, end, selTop, onTop } = coords;
+
+ const anchor = getMenuAnchor(coords, rect, isRtl);
+
+ // Calculate Left: shift by width if RTL, then make relative to container
+ const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
+
+ // Ensure menu stays within container bounds
+ const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
+
+ // Calculate Top: align to selection or bottom of selection
+ const top = onTop
+ ? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
+ : Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
+ return { left, top, width: MENU_CONFIG.W };
+}
+
+/* End Menu Positioning Helpers */
diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
index 56b162d50..4efb4d1d9 100644
--- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js
@@ -1,15 +1,11 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
-import {
- MessageMarkdownTransformer,
- messageSchema,
-} from '@chatwoot/prosemirror-schema';
+import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
- messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
- replaceVariablesInMessage.mockReturnValue(updatedMessage);
- MessageMarkdownTransformer.mockImplementation(() => ({
- parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
- }));
+ // Mock the node that will be returned by parse
+ const mockNode = { textContent: updatedMessage };
- const { node } = getContentNode(
+ replaceVariablesInMessage.mockReturnValue(updatedMessage);
+
+ // Mock MessageMarkdownTransformer instance with parse method
+ const mockTransformer = {
+ parse: vi.fn().mockReturnValue(mockNode),
+ };
+ MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
+
+ const result = getContentNode(
editorView,
'cannedResponse',
content,
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
message: content,
variables,
});
- expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
- expect(node.textContent).toBe(updatedMessage);
+ expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
+ editorView.state.schema
+ );
+ expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
+ expect(result.node).toBe(mockNode);
+ expect(result.node.textContent).toBe(updatedMessage);
+ // When textContent matches updatedMessage, from should remain unchanged
+ expect(result.from).toBe(from);
+ expect(result.to).toBe(to);
});
});
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index 4ed9170c2..abdd07e6a 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -9,7 +9,13 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
+ getFormattingForEditor,
+ getSelectionCoords,
+ getMenuAnchor,
+ calculateMenuPosition,
+ stripUnsupportedFormatting,
} from '../editorHelper';
+import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -258,15 +264,11 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
- it('should unwrap doc nodes that are wrapped in a paragraph', () => {
- const docNode = schema.node('doc', null, [
- schema.node('paragraph', null, [schema.text('Hello')]),
- ]);
-
+ it('should insert text node at cursor position', () => {
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
- insertAtCursor(editorView, docNode, 0);
+ insertAtCursor(editorView, schema.text('Hello'), 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -626,3 +628,329 @@ describe('getContentNode', () => {
});
});
});
+
+describe('getFormattingForEditor', () => {
+ describe('channel-specific formatting', () => {
+ it('returns full formatting for Email channel', () => {
+ const result = getFormattingForEditor('Channel::Email');
+
+ expect(result).toEqual(FORMATTING['Channel::Email']);
+ });
+
+ it('returns full formatting for WebWidget channel', () => {
+ const result = getFormattingForEditor('Channel::WebWidget');
+
+ expect(result).toEqual(FORMATTING['Channel::WebWidget']);
+ });
+
+ it('returns limited formatting for WhatsApp channel', () => {
+ const result = getFormattingForEditor('Channel::Whatsapp');
+
+ expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
+ });
+
+ it('returns no formatting for API channel', () => {
+ const result = getFormattingForEditor('Channel::Api');
+
+ expect(result).toEqual(FORMATTING['Channel::Api']);
+ });
+
+ it('returns limited formatting for FacebookPage channel', () => {
+ const result = getFormattingForEditor('Channel::FacebookPage');
+
+ expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
+ });
+
+ it('returns no formatting for TwitterProfile channel', () => {
+ const result = getFormattingForEditor('Channel::TwitterProfile');
+
+ expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
+ });
+
+ it('returns no formatting for SMS channel', () => {
+ const result = getFormattingForEditor('Channel::Sms');
+
+ expect(result).toEqual(FORMATTING['Channel::Sms']);
+ });
+
+ it('returns limited formatting for Telegram channel', () => {
+ const result = getFormattingForEditor('Channel::Telegram');
+
+ expect(result).toEqual(FORMATTING['Channel::Telegram']);
+ });
+
+ it('returns formatting for Instagram channel', () => {
+ const result = getFormattingForEditor('Channel::Instagram');
+
+ expect(result).toEqual(FORMATTING['Channel::Instagram']);
+ });
+ });
+
+ describe('context-specific formatting', () => {
+ it('returns default formatting for Context::Default', () => {
+ const result = getFormattingForEditor('Context::Default');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns signature formatting for Context::MessageSignature', () => {
+ const result = getFormattingForEditor('Context::MessageSignature');
+
+ expect(result).toEqual(FORMATTING['Context::MessageSignature']);
+ });
+
+ it('returns widget builder formatting for Context::InboxSettings', () => {
+ const result = getFormattingForEditor('Context::InboxSettings');
+
+ expect(result).toEqual(FORMATTING['Context::InboxSettings']);
+ });
+ });
+
+ describe('fallback behavior', () => {
+ it('returns default formatting for unknown channel type', () => {
+ const result = getFormattingForEditor('Channel::Unknown');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for null channel type', () => {
+ const result = getFormattingForEditor(null);
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for undefined channel type', () => {
+ const result = getFormattingForEditor(undefined);
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+
+ it('returns default formatting for empty string', () => {
+ const result = getFormattingForEditor('');
+
+ expect(result).toEqual(FORMATTING['Context::Default']);
+ });
+ });
+
+ describe('return value structure', () => {
+ it('always returns an object with marks, nodes, and menu properties', () => {
+ const result = getFormattingForEditor('Channel::Email');
+
+ expect(result).toHaveProperty('marks');
+ expect(result).toHaveProperty('nodes');
+ expect(result).toHaveProperty('menu');
+ expect(Array.isArray(result.marks)).toBe(true);
+ expect(Array.isArray(result.nodes)).toBe(true);
+ expect(Array.isArray(result.menu)).toBe(true);
+ });
+ });
+});
+
+describe('stripUnsupportedFormatting', () => {
+ describe('when schema supports all formatting', () => {
+ const fullSchema = {
+ marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
+ nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
+ };
+
+ it('preserves all formatting when schema supports it', () => {
+ const content = '**bold** and *italic* and `code`';
+ expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
+ });
+
+ it('preserves links when schema supports them', () => {
+ const content = 'Check [this link](https://example.com)';
+ expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
+ });
+
+ it('preserves lists when schema supports them', () => {
+ const content = '- item 1\n- item 2\n1. first\n2. second';
+ expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
+ });
+ });
+
+ describe('when schema has no formatting support (eg:SMS channel)', () => {
+ const emptySchema = {
+ marks: {},
+ nodes: {},
+ };
+
+ it('strips bold formatting', () => {
+ expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
+ 'bold text'
+ );
+ expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
+ 'bold text'
+ );
+ });
+
+ it('strips italic formatting', () => {
+ expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
+ 'italic text'
+ );
+ expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
+ 'italic text'
+ );
+ });
+
+ it('strips inline code formatting', () => {
+ expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
+ 'inline code'
+ );
+ });
+
+ it('strips strikethrough formatting', () => {
+ expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
+ 'strikethrough'
+ );
+ });
+
+ it('strips links but keeps text', () => {
+ expect(
+ stripUnsupportedFormatting(
+ 'Check [this link](https://example.com)',
+ emptySchema
+ )
+ ).toBe('Check this link');
+ });
+
+ it('strips bullet list markers', () => {
+ expect(
+ stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
+ ).toBe('item 1\nitem 2');
+ expect(
+ stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
+ ).toBe('item 1\nitem 2');
+ });
+
+ it('strips ordered list markers', () => {
+ expect(
+ stripUnsupportedFormatting('1. first\n2. second', emptySchema)
+ ).toBe('first\nsecond');
+ });
+
+ it('strips code block markers', () => {
+ expect(
+ stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
+ ).toBe('code here\n');
+ });
+
+ it('strips blockquote markers', () => {
+ expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
+ 'quoted text'
+ );
+ });
+
+ it('handles complex content with multiple formatting types', () => {
+ const content =
+ '**Bold** and *italic* with `code` and [link](url)\n- list item';
+ const expected = 'Bold and italic with code and link\nlist item';
+ expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
+ });
+ });
+
+ describe('when schema has partial support', () => {
+ const partialSchema = {
+ marks: { strong: {}, em: {} },
+ nodes: {},
+ };
+
+ it('preserves supported marks and strips unsupported ones', () => {
+ const content = '**bold** and `code`';
+ expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
+ '**bold** and code'
+ );
+ });
+
+ it('strips unsupported nodes but keeps supported marks', () => {
+ const content = '**bold** text\n- list item';
+ expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
+ '**bold** text\nlist item'
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns content unchanged if content is empty', () => {
+ expect(stripUnsupportedFormatting('', {})).toBe('');
+ });
+
+ it('returns content unchanged if content is null', () => {
+ expect(stripUnsupportedFormatting(null, {})).toBe(null);
+ });
+
+ it('returns content unchanged if content is undefined', () => {
+ expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
+ });
+
+ it('returns content unchanged if schema is null', () => {
+ expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
+ });
+
+ it('handles nested formatting correctly', () => {
+ const emptySchema = { marks: {}, nodes: {} };
+ // After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
+ expect(
+ stripUnsupportedFormatting('**bold *and italic***', emptySchema)
+ ).toBe('bold and italic');
+ });
+ });
+});
+
+describe('Menu positioning helpers', () => {
+ const mockEditorView = {
+ coordsAtPos: vi.fn((pos, bias) => {
+ // Return different coords based on position
+ if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
+ return { top: 100, bottom: 120, left: 150, right: 200 };
+ }),
+ };
+
+ const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
+
+ describe('getSelectionCoords', () => {
+ it('returns selection coordinates with onTop flag', () => {
+ const selection = { from: 0, to: 10 };
+ const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
+
+ expect(result).toHaveProperty('start');
+ expect(result).toHaveProperty('end');
+ expect(result).toHaveProperty('selTop');
+ expect(result).toHaveProperty('onTop');
+ });
+ });
+
+ describe('getMenuAnchor', () => {
+ it('returns end.left when menu is below selection', () => {
+ const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
+ expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
+ });
+
+ it('returns start.left for LTR when menu is above and visible', () => {
+ const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
+ expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
+ });
+
+ it('returns start.right for RTL when menu is above and visible', () => {
+ const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
+ expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
+ });
+ });
+
+ describe('calculateMenuPosition', () => {
+ it('returns bounded left and top positions', () => {
+ const coords = {
+ start: { top: 100, bottom: 120, left: 50 },
+ end: { top: 100, bottom: 120, left: 150 },
+ selTop: 100,
+ onTop: false,
+ };
+ const result = calculateMenuPosition(coords, wrapperRect, false);
+
+ expect(result).toHaveProperty('left');
+ expect(result).toHaveProperty('top');
+ expect(result).toHaveProperty('width', 300);
+ expect(result.left).toBeGreaterThanOrEqual(0);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 79d5ebc66..cfb41615b 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -196,7 +196,6 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
- "TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index b53bfed7b..4e51ef37a 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -399,15 +399,39 @@
"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."
+ "RESPONSES": "Credits",
+ "UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
+ "REFRESH_CREDITS": "Refresh"
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
- "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
+ "NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
+ "TOPUP": {
+ "BUY_CREDITS": "Buy more credits",
+ "MODAL_TITLE": "Buy AI Credits",
+ "MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
+ "CREDITS": "CREDITS",
+ "ONE_TIME": "one-time",
+ "POPULAR": "Most Popular",
+ "NOTE_TITLE": "Note:",
+ "NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
+ "CANCEL": "Cancel",
+ "PURCHASE": "Purchase Credits",
+ "LOADING": "Loading options...",
+ "FETCH_ERROR": "Failed to load credit options. Please try again.",
+ "PURCHASE_ERROR": "Failed to process purchase. Please try again.",
+ "PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
+ "CONFIRM": {
+ "TITLE": "Confirm Purchase",
+ "DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
+ "INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
+ "GO_BACK": "Go Back",
+ "CONFIRM_PURCHASE": "Confirm Purchase"
+ }
+ }
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 17e8dfd25..b163bfdc8 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
@@ -5,7 +5,9 @@ import {
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
+import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
@@ -52,12 +54,22 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
+const { isCloudFeatureEnabled } = useAccount();
+
+const isLinearFeatureEnabled = computed(() =>
+ isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
+);
+
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
-const isLinearIntegrationEnabled = computed(
+const isLinearClientIdConfigured = computed(() => {
+ return !!linearIntegration.value?.id;
+});
+
+const isLinearConnected = computed(
() => linearIntegration.value?.enabled || false
);
@@ -238,7 +250,13 @@ onMounted(() => {
-
+
{
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
index 4ae140120..bcfa46193 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
+import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -23,6 +24,7 @@ const {
documentLimits,
responseLimits,
fetchLimits,
+ isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
+const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
@@ -45,6 +48,11 @@ const planName = computed(() => {
return customAttributes.value.plan_name;
});
+const canPurchaseCredits = computed(() => {
+ const plan = planName.value?.toLowerCase();
+ return plan && plan !== 'hacker';
+});
+
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
- fetchLimits();
}
+ // Always fetch limits for billing page to show credit usage
+ fetchLimits();
};
const handleBillingPageLogic = async () => {
@@ -119,6 +128,15 @@ const onToggleChatWindow = () => {
}
};
+const openPurchaseCreditsModal = () => {
+ purchaseCreditsModalRef.value?.open();
+};
+
+const handleTopupSuccess = () => {
+ // Refresh limits to show updated credit balance
+ fetchLimits();
+};
+
onMounted(handleBillingPageLogic);
@@ -178,9 +196,27 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
-
- {{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
-
+
+
+ {{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
+
+
+ {{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue
new file mode 100644
index 000000000..d557e9a71
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
new file mode 100644
index 000000000..33f299b9b
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
index 3ec03d5ef..56caa2558 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
@@ -110,6 +110,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
+ channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
index 26a590ed3..d2c906511 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
@@ -114,6 +114,7 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
+ channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index bbd0d9000..6a10d0986 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
-import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -81,7 +80,6 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
- welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -626,7 +624,7 @@ export default {
)
"
:max-length="255"
- :enabled-menu-options="welcomeTaglineEditorMenuOptions"
+ channel-type="Context::InboxSettings"
/>