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 f037583e7..1ae6cf093 100644
--- a/Gemfile
+++ b/Gemfile
@@ -191,10 +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.9.1'
+gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
@@ -215,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 c3eec459b..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.9.1)
+ ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -827,7 +827,6 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
- ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
@@ -1018,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)
@@ -1120,7 +1119,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
- ruby_llm (>= 1.9.1)
+ ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb
index 7bc571f6c..8821da9d5 100644
--- a/app/builders/messages/messenger/message_builder.rb
+++ b/app/builders/messages/messenger/message_builder.rb
@@ -101,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/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/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 8d7a082b4..323b911ec 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
-import {
- MESSAGE_EDITOR_MENU_OPTIONS,
- MESSAGE_EDITOR_IMAGE_RESIZES,
-} from 'dashboard/constants/editor';
+import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
messageSchema,
+ buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -53,6 +51,9 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
+ getFormattingForEditor,
+ getSelectionCoords,
+ calculateMenuPosition,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -75,7 +76,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
- enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
@@ -103,22 +103,34 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
+const DEFAULT_FORMATTING = 'Context::Default';
-const createState = (
- content,
- placeholder,
- plugins = [],
- methods = {},
- enabledMenuOptions = []
-) => {
+const editorSchema = computed(() => {
+ if (!props.channelType) return messageSchema;
+
+ const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
+ const formatting = getFormattingForEditor(formatType);
+ return buildMessageSchema(formatting.marks, formatting.nodes);
+});
+
+const editorMenuOptions = computed(() => {
+ const formatType = props.isPrivate
+ ? DEFAULT_FORMATTING
+ : props.channelType || DEFAULT_FORMATTING;
+ const formatting = getFormattingForEditor(formatType);
+ return formatting.menu;
+});
+
+const createState = (content, placeholder, plugins = [], methods = {}) => {
+ const schema = editorSchema.value;
return EditorState.create({
- doc: new MessageMarkdownTransformer(messageSchema).parse(content),
+ doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
- schema: messageSchema,
+ schema,
placeholder,
methods,
plugins,
- enabledMenuOptions,
+ enabledMenuOptions: editorMenuOptions.value,
}),
});
};
@@ -153,6 +165,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
+const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
+const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -174,12 +188,6 @@ const shouldShowCannedResponses = computed(() => {
);
});
-const editorMenuOptions = computed(() => {
- return props.enabledMenuOptions.length
- ? props.enabledMenuOptions
- : MESSAGE_EDITOR_MENU_OPTIONS;
-});
-
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -400,6 +408,38 @@ function setToolbarPosition() {
};
}
+function setMenubarPosition({ selection } = {}) {
+ const wrapper = editorRoot.value;
+ if (!selection || !wrapper) return;
+
+ const rect = wrapper.getBoundingClientRect();
+ const isRtl = getComputedStyle(wrapper).direction === 'rtl';
+
+ // Calculate coords and final position
+ const coords = getSelectionCoords(editorView, selection, rect);
+ const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
+
+ wrapper.style.setProperty('--selection-left', `${left}px`);
+ wrapper.style.setProperty(
+ '--selection-right',
+ `${rect.width - left - width}px`
+ );
+ wrapper.style.setProperty('--selection-top', `${top}px`);
+}
+
+function checkSelection(editorState) {
+ showSelectionMenu.value = false;
+ const hasSelection = editorState.selection.from !== editorState.selection.to;
+ if (hasSelection === isTextSelected.value) return;
+
+ isTextSelected.value = hasSelection;
+ const wrapper = editorRoot.value;
+ if (!wrapper) return;
+
+ wrapper.classList.toggle('has-selection', hasSelection);
+ if (hasSelection) setMenubarPosition(editorState);
+}
+
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -529,7 +569,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
- let node = new MessageMarkdownTransformer(messageSchema).parse(content);
+ // Use the editor's current schema to ensure compatibility with buildMessageSchema
+ const currentSchema = editorView.state.schema;
+ let node = new MessageMarkdownTransformer(currentSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -596,6 +638,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
+ checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -761,15 +804,33 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
- @apply flex flex-col;
+ @apply flex flex-col gap-3;
.ProseMirror-menubar {
min-height: 1.25rem !important;
- @apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
+ @apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
.ProseMirror-menu-active {
- @apply bg-n-slate-5 dark:bg-n-solid-3;
+ @apply bg-n-slate-5 dark:bg-n-solid-3 !important;
}
+
+ .ProseMirror-menuitem {
+ @apply mr-0 size-4 flex items-center justify-center;
+
+ .ProseMirror-icon {
+ @apply size-4 flex items-center justify-center flex-shrink-0;
+
+ svg {
+ @apply size-full;
+ }
+ }
+ }
+ }
+
+ .ProseMirror-menubar:not(:has(*)) {
+ max-height: none !important;
+ min-height: 0 !important;
+ padding: 0 !important;
}
> .ProseMirror {
@@ -860,4 +921,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
+
+// Float editor menu
+.popover-prosemirror-menu {
+ position: relative;
+
+ .ProseMirror p:last-child {
+ margin-bottom: 10px !important;
+ }
+
+ .ProseMirror-menubar {
+ display: none; // Hide by default
+ }
+
+ &.has-selection {
+ // Hide menu completely when it has no items
+ .ProseMirror-menubar:not(:has(*)) {
+ display: none !important;
+ }
+
+ .ProseMirror-menubar {
+ @apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
+ display: flex;
+ width: fit-content !important;
+ position: absolute !important;
+
+ // Default/LTR: position from left
+ top: var(--selection-top);
+ left: var(--selection-left);
+
+ // RTL: position from right instead
+ [dir='rtl'] & {
+ left: auto;
+ right: var(--selection-right);
+ }
+
+ .ProseMirror-menuitem {
+ @apply mr-0 size-4 flex items-center;
+
+ .ProseMirror-icon {
+ @apply p-0.5 flex-shrink-0;
+ }
+ }
+
+ .ProseMirror-menu-active {
+ @apply bg-n-slate-3;
+ }
+ }
+ }
+}
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index 67d1dc4b3..8c863c16a 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -73,10 +73,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
- showEditorToggle: {
- type: Boolean,
- default: false,
- },
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -126,7 +122,6 @@ const props = defineProps({
const emit = defineEmits([
'replaceText',
'toggleInsertArticle',
- 'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -332,26 +327,12 @@ onMounted(() => {
sm
@click="toggleAudioRecorder"
/>
-
-
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/constants/editor.js b/app/javascript/dashboard/constants/editor.js
index 9a99516e8..9633687ec 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: [],
+ nodes: [],
+ menu: [],
+ },
+ '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,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
-export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
- 'strong',
- 'em',
- 'link',
- 'undo',
- 'redo',
-];
-
+// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index ae4c4a8cc..3722bbeed 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -5,6 +5,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
+import { FORMATTING } from 'dashboard/constants/editor';
/**
* The delimiter used to separate the signature from the rest of the body.
@@ -314,7 +315,7 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse':
- return new MessageMarkdownTransformer(messageSchema).parse(content);
+ return new MessageMarkdownTransformer(state.schema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -389,3 +390,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..664a1a42f 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -9,7 +9,12 @@ import {
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
+ getFormattingForEditor,
+ getSelectionCoords,
+ getMenuAnchor,
+ calculateMenuPosition,
} 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 +263,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 +627,178 @@ 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('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 7afd60483..23550ca7c 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 64be235a2..4e51ef37a 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -422,7 +422,15 @@
"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_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": {
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
index 39cd4ccb7..08dde29be 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 Icon from 'dashboard/components-next/icon/Icon.vue';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
@@ -53,12 +55,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
);
@@ -241,7 +253,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 4f0981967..bcfa46193 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue
@@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
+const handleTopupSuccess = () => {
+ // Refresh limits to show updated credit balance
+ fetchLimits();
+};
+
onMounted(handleBillingPageLogic);
@@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic);
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
index ebbab0aa8..33f299b9b 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
-const emit = defineEmits(['close']);
+const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
@@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [
];
const POPULAR_CREDITS_AMOUNT = 6000;
+const STEP_SELECT = 'select';
+const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
+const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
+const formattedAmount = computed(() => {
+ if (!selectedOption.value) return '';
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: selectedOption.value.currency.toUpperCase(),
+ }).format(selectedOption.value.amount);
+});
+
+const formattedCredits = computed(() => {
+ if (!selectedOption.value) return '';
+ return selectedOption.value.credits.toLocaleString();
+});
+
+const dialogTitle = computed(() => {
+ return currentStep.value === STEP_SELECT
+ ? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
+ : t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
+});
+
+const dialogDescription = computed(() => {
+ return currentStep.value === STEP_SELECT
+ ? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
+ : '';
+});
+
+const dialogWidth = computed(() => {
+ return currentStep.value === STEP_SELECT ? 'xl' : 'md';
+});
+
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
+const open = () => {
+ const popularOption = TOPUP_OPTIONS.find(
+ o => o.credits === POPULAR_CREDITS_AMOUNT
+ );
+ selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
+ currentStep.value = STEP_SELECT;
+ isLoading.value = false;
+ dialogRef.value?.open();
+};
+
+const close = () => {
+ dialogRef.value?.close();
+};
+
+const handleClose = () => {
+ emit('close');
+};
+
+const goToConfirmStep = () => {
+ if (!selectedOption.value) return;
+ currentStep.value = STEP_CONFIRM;
+};
+
+const goBackToSelectStep = () => {
+ currentStep.value = STEP_SELECT;
+};
+
const handlePurchase = async () => {
if (!selectedOption.value) return;
@@ -40,9 +99,14 @@ const handlePurchase = async () => {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
- if (response.data.redirect_url) {
- window.location.href = response.data.redirect_url;
- }
+
+ close();
+ emit('success', response.data);
+ useAlert(
+ t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
+ credits: response.data.credits,
+ })
+ );
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
@@ -52,61 +116,71 @@ const handlePurchase = async () => {
}
};
-const handleClose = () => {
- emit('close');
-};
-
-const open = () => {
- // Pre-select the most popular option
- const popularOption = TOPUP_OPTIONS.find(
- o => o.credits === POPULAR_CREDITS_AMOUNT
- );
- selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
- dialogRef.value?.open();
-};
-
-const close = () => {
- dialogRef.value?.close();
-};
-
defineExpose({ open, close });