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: ![alt](url) + 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')" >
+ 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" />