diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
index 4c7ef8aa8..ee20fded5 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue
@@ -108,6 +108,7 @@ watch(
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
+ class="z-0"
/>
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
index 8fb612bc4..0d5841e5a 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue
@@ -1,5 +1,6 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index b7ede029f..5a7d39a4e 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -1,41 +1,102 @@
-
+
-
+
- {{ $t(labelKey) }}
- {{ $t(subtextKey) }}
+
+ {{ $t(labelKey) }}
+
+
+ {{ $t(subtextKey) }}
+
diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js
index 77ea25533..9efae789c 100644
--- a/app/javascript/dashboard/components-next/message/constants.js
+++ b/app/javascript/dashboard/components-next/message/constants.js
@@ -49,6 +49,9 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
+ EMBED: 'embed',
+ IG_POST: 'ig_post',
+ IG_STORY: 'ig_story',
};
export const CONTENT_TYPES = {
@@ -73,3 +76,16 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
+
+export const VOICE_CALL_STATUS = {
+ IN_PROGRESS: 'in-progress',
+ RINGING: 'ringing',
+ COMPLETED: 'completed',
+ NO_ANSWER: 'no-answer',
+ FAILED: 'failed',
+};
+
+export const VOICE_CALL_DIRECTION = {
+ INBOUND: 'inbound',
+ OUTBOUND: 'outbound',
+};
diff --git a/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
index e58b2db86..0a919dbbf 100644
--- a/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
+++ b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
@@ -1,6 +1,7 @@
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
/>
- {{ currentPage }}
+ {{ formatFullNumber(currentPage) }}
+
+
+ {{ pageInfo }}
- {{ pageInfo }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Tiktok.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Tiktok.vue
new file mode 100644
index 000000000..50a326835
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Tiktok.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
{{ errorStateMessage }}
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
+
+
+ {{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue
index 6f21acd52..9b5907120 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Website.vue
@@ -5,7 +5,6 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
-import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -24,7 +23,6 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
- welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -147,7 +145,7 @@ export default {
)
"
:max-length="255"
- :enabled-menu-options="welcomeTaglineEditorMenuOptions"
+ channel-type="Context::InboxSettings"
class="mb-4"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/tiktok/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/tiktok/Reauthorize.vue
new file mode 100644
index 000000000..5d48c6f8c
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/tiktok/Reauthorize.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
index 31e1dc9da..929079063 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
@@ -33,6 +33,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
+ 'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
index ef87e3c43..7f113eeb9 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
@@ -3,7 +3,6 @@ import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
-import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -28,7 +27,6 @@ const emit = defineEmits(['updateSignature']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
-const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
const signaturePosition = ref(props.signaturePosition);
const signatureSeparator = ref(props.signatureSeparator);
@@ -195,7 +193,7 @@ const handleSeparatorChange = value => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
- :enabled-menu-options="customEditorMenuList"
+ channel-type="Context::MessageSignature"
:enable-suggestions="false"
show-image-resize-toolbar
/>
diff --git a/app/javascript/dashboard/store/captain/assistant.js b/app/javascript/dashboard/store/captain/assistant.js
index 405e3d9c8..e690944ba 100644
--- a/app/javascript/dashboard/store/captain/assistant.js
+++ b/app/javascript/dashboard/store/captain/assistant.js
@@ -1,5 +1,5 @@
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainAssistant',
diff --git a/app/javascript/dashboard/store/captain/bulkActions.js b/app/javascript/dashboard/store/captain/bulkActions.js
index 7c8ea058f..42ffcd294 100644
--- a/app/javascript/dashboard/store/captain/bulkActions.js
+++ b/app/javascript/dashboard/store/captain/bulkActions.js
@@ -1,5 +1,5 @@
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
diff --git a/app/javascript/dashboard/store/captain/copilotMessages.js b/app/javascript/dashboard/store/captain/copilotMessages.js
index 2b296cdc1..7b73ee89f 100644
--- a/app/javascript/dashboard/store/captain/copilotMessages.js
+++ b/app/javascript/dashboard/store/captain/copilotMessages.js
@@ -1,5 +1,5 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotMessages',
diff --git a/app/javascript/dashboard/store/captain/copilotThreads.js b/app/javascript/dashboard/store/captain/copilotThreads.js
index 8d820f305..5b0f07756 100644
--- a/app/javascript/dashboard/store/captain/copilotThreads.js
+++ b/app/javascript/dashboard/store/captain/copilotThreads.js
@@ -1,5 +1,5 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotThreads',
diff --git a/app/javascript/dashboard/store/captain/customTools.js b/app/javascript/dashboard/store/captain/customTools.js
index 3d3af03c0..62cb4c3c5 100644
--- a/app/javascript/dashboard/store/captain/customTools.js
+++ b/app/javascript/dashboard/store/captain/customTools.js
@@ -1,5 +1,5 @@
import CaptainCustomTools from 'dashboard/api/captain/customTools';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
diff --git a/app/javascript/dashboard/store/captain/document.js b/app/javascript/dashboard/store/captain/document.js
index 25d9c3b94..f5766f827 100644
--- a/app/javascript/dashboard/store/captain/document.js
+++ b/app/javascript/dashboard/store/captain/document.js
@@ -1,5 +1,5 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
diff --git a/app/javascript/dashboard/store/captain/inboxes.js b/app/javascript/dashboard/store/captain/inboxes.js
index ef66e4fe4..78182979f 100644
--- a/app/javascript/dashboard/store/captain/inboxes.js
+++ b/app/javascript/dashboard/store/captain/inboxes.js
@@ -1,5 +1,5 @@
import CaptainInboxes from 'dashboard/api/captain/inboxes';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
diff --git a/app/javascript/dashboard/store/captain/response.js b/app/javascript/dashboard/store/captain/response.js
index 0822b43cd..a818fe9fd 100644
--- a/app/javascript/dashboard/store/captain/response.js
+++ b/app/javascript/dashboard/store/captain/response.js
@@ -1,5 +1,5 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
diff --git a/app/javascript/dashboard/store/captain/scenarios.js b/app/javascript/dashboard/store/captain/scenarios.js
index d66992d85..1ee5b2bd6 100644
--- a/app/javascript/dashboard/store/captain/scenarios.js
+++ b/app/javascript/dashboard/store/captain/scenarios.js
@@ -1,5 +1,5 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js
deleted file mode 100644
index d57b98862..000000000
--- a/app/javascript/dashboard/store/captain/storeFactory.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
-import {
- createRecord,
- deleteRecord,
- getRecords,
- showRecord,
- updateRecord,
-} from './storeFactoryHelper';
-
-export const generateMutationTypes = name => {
- const capitalizedName = name.toUpperCase();
- return {
- SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
- SET: `SET_${capitalizedName}`,
- ADD: `ADD_${capitalizedName}`,
- EDIT: `EDIT_${capitalizedName}`,
- DELETE: `DELETE_${capitalizedName}`,
- SET_META: `SET_${capitalizedName}_META`,
- UPSERT: `UPSERT_${capitalizedName}`,
- };
-};
-
-export const createInitialState = () => ({
- records: [],
- meta: {},
- uiFlags: {
- fetchingList: false,
- fetchingItem: false,
- creatingItem: false,
- updatingItem: false,
- deletingItem: false,
- },
-});
-
-export const createGetters = () => ({
- getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
- getRecord: state => id =>
- state.records.find(record => record.id === Number(id)) || {},
- getUIFlags: state => state.uiFlags,
- getMeta: state => state.meta,
-});
-
-export const createMutations = mutationTypes => ({
- [mutationTypes.SET_UI_FLAG](state, data) {
- state.uiFlags = {
- ...state.uiFlags,
- ...data,
- };
- },
- [mutationTypes.SET_META](state, meta) {
- state.meta = {
- ...state.meta,
- totalCount: Number(meta.total_count),
- page: Number(meta.page),
- };
- },
- [mutationTypes.SET]: MutationHelpers.set,
- [mutationTypes.ADD]: MutationHelpers.create,
- [mutationTypes.EDIT]: MutationHelpers.update,
- [mutationTypes.DELETE]: MutationHelpers.destroy,
- [mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
-});
-
-export const createCrudActions = (API, mutationTypes) => ({
- get: getRecords(mutationTypes, API),
- show: showRecord(mutationTypes, API),
- create: createRecord(mutationTypes, API),
- update: updateRecord(mutationTypes, API),
- delete: deleteRecord(mutationTypes, API),
-});
-
-export const createStore = options => {
- const { name, API, actions, getters, mutations } = options;
- const mutationTypes = generateMutationTypes(name);
-
- const customActions = actions ? actions(mutationTypes) : {};
-
- return {
- namespaced: true,
- state: createInitialState(),
- getters: {
- ...createGetters(),
- ...(getters || {}),
- },
- mutations: {
- ...createMutations(mutationTypes),
- ...(mutations || {}),
- },
- actions: {
- ...createCrudActions(API, mutationTypes),
- ...customActions,
- },
- };
-};
diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js
deleted file mode 100644
index 44637d429..000000000
--- a/app/javascript/dashboard/store/captain/storeFactory.spec.js
+++ /dev/null
@@ -1,380 +0,0 @@
-import { throwErrorMessage } from 'dashboard/store/utils/api';
-import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
-import {
- generateMutationTypes,
- createInitialState,
- createGetters,
- createMutations,
- createCrudActions,
- createStore,
-} from './storeFactory';
-
-vi.mock('dashboard/store/utils/api', () => ({
- throwErrorMessage: vi.fn(),
-}));
-
-vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
- set: vi.fn(),
- create: vi.fn(),
- update: vi.fn(),
- destroy: vi.fn(),
- setSingleRecord: vi.fn(),
-}));
-
-describe('storeFactory', () => {
- describe('generateMutationTypes', () => {
- it('generates correct mutation types with capitalized name', () => {
- const result = generateMutationTypes('test');
- expect(result).toEqual({
- SET_UI_FLAG: 'SET_TEST_UI_FLAG',
- SET: 'SET_TEST',
- ADD: 'ADD_TEST',
- EDIT: 'EDIT_TEST',
- DELETE: 'DELETE_TEST',
- SET_META: 'SET_TEST_META',
- UPSERT: 'UPSERT_TEST',
- });
- });
- });
-
- describe('createInitialState', () => {
- it('returns the correct initial state structure', () => {
- const result = createInitialState();
- expect(result).toEqual({
- records: [],
- meta: {},
- uiFlags: {
- fetchingList: false,
- fetchingItem: false,
- creatingItem: false,
- updatingItem: false,
- deletingItem: false,
- },
- });
- });
- });
-
- describe('createGetters', () => {
- it('returns getters with correct implementations', () => {
- const getters = createGetters();
-
- const state = {
- records: [{ id: 2 }, { id: 1 }, { id: 3 }],
- uiFlags: { fetchingList: true },
- meta: { totalCount: 10, page: 1 },
- };
- expect(getters.getRecords(state)).toEqual([
- { id: 3 },
- { id: 2 },
- { id: 1 },
- ]);
-
- expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
- expect(getters.getRecord(state)(4)).toEqual({});
-
- expect(getters.getUIFlags(state)).toEqual({
- fetchingList: true,
- });
-
- expect(getters.getMeta(state)).toEqual({
- totalCount: 10,
- page: 1,
- });
- });
- });
-
- describe('createMutations', () => {
- it('creates mutations with correct implementations', () => {
- const mutationTypes = generateMutationTypes('test');
- const mutations = createMutations(mutationTypes);
-
- const state = { uiFlags: { fetchingList: false } };
- mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
- expect(state.uiFlags).toEqual({ fetchingList: true });
-
- const metaState = { meta: {} };
- mutations[mutationTypes.SET_META](metaState, {
- total_count: '10',
- page: '2',
- });
- expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
-
- expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
- expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
- expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
- expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
- expect(mutations[mutationTypes.UPSERT]).toBe(
- MutationHelpers.setSingleRecord
- );
- });
- });
-
- describe('createCrudActions', () => {
- let API;
- let commit;
- let mutationTypes;
- let actions;
-
- beforeEach(() => {
- API = {
- get: vi.fn(),
- show: vi.fn(),
- create: vi.fn(),
- update: vi.fn(),
- delete: vi.fn(),
- };
- commit = vi.fn();
- mutationTypes = generateMutationTypes('test');
- actions = createCrudActions(API, mutationTypes);
- });
-
- describe('get action', () => {
- it('handles successful API response', async () => {
- const payload = [{ id: 1 }];
- const meta = { total_count: 10, page: 1 };
- API.get.mockResolvedValue({ data: { payload, meta } });
-
- const result = await actions.get({ commit });
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingList: true,
- });
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingList: false,
- });
- expect(result).toEqual(payload);
- });
-
- it('handles API error', async () => {
- const error = new Error('API Error');
- API.get.mockRejectedValue(error);
- throwErrorMessage.mockReturnValue('Error thrown');
-
- const result = await actions.get({ commit });
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingList: true,
- });
- expect(throwErrorMessage).toHaveBeenCalledWith(error);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingList: false,
- });
- expect(result).toEqual('Error thrown');
- });
- });
-
- describe('show action', () => {
- it('handles successful API response', async () => {
- const data = { id: 1, name: 'Test' };
- API.show.mockResolvedValue({ data });
-
- const result = await actions.show({ commit }, 1);
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingItem: true,
- });
- expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingItem: false,
- });
- expect(result).toEqual(data);
- });
-
- it('handles API error', async () => {
- const error = new Error('API Error');
- API.show.mockRejectedValue(error);
- throwErrorMessage.mockReturnValue('Error thrown');
-
- const result = await actions.show({ commit }, 1);
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingItem: true,
- });
- expect(throwErrorMessage).toHaveBeenCalledWith(error);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- fetchingItem: false,
- });
- expect(result).toEqual('Error thrown');
- });
- });
-
- describe('create action', () => {
- it('handles successful API response', async () => {
- const data = { id: 1, name: 'Test' };
- API.create.mockResolvedValue({ data });
-
- const result = await actions.create({ commit }, { name: 'Test' });
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- creatingItem: true,
- });
- expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- creatingItem: false,
- });
- expect(result).toEqual(data);
- });
-
- it('handles API error', async () => {
- const error = new Error('API Error');
- API.create.mockRejectedValue(error);
- throwErrorMessage.mockReturnValue('Error thrown');
-
- const result = await actions.create({ commit }, { name: 'Test' });
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- creatingItem: true,
- });
- expect(throwErrorMessage).toHaveBeenCalledWith(error);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- creatingItem: false,
- });
- expect(result).toEqual('Error thrown');
- });
- });
-
- describe('update action', () => {
- it('handles successful API response', async () => {
- const data = { id: 1, name: 'Updated' };
- API.update.mockResolvedValue({ data });
-
- const result = await actions.update(
- { commit },
- { id: 1, name: 'Updated' }
- );
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- updatingItem: true,
- });
- expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
- expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- updatingItem: false,
- });
- expect(result).toEqual(data);
- });
-
- it('handles API error', async () => {
- const error = new Error('API Error');
- API.update.mockRejectedValue(error);
- throwErrorMessage.mockReturnValue('Error thrown');
-
- const result = await actions.update(
- { commit },
- { id: 1, name: 'Updated' }
- );
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- updatingItem: true,
- });
- expect(throwErrorMessage).toHaveBeenCalledWith(error);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- updatingItem: false,
- });
- expect(result).toEqual('Error thrown');
- });
- });
-
- describe('delete action', () => {
- it('handles successful API response', async () => {
- API.delete.mockResolvedValue({});
-
- const result = await actions.delete({ commit }, 1);
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- deletingItem: true,
- });
- expect(API.delete).toHaveBeenCalledWith(1);
- expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- deletingItem: false,
- });
- expect(result).toEqual(1);
- });
-
- it('handles API error', async () => {
- const error = new Error('API Error');
- API.delete.mockRejectedValue(error);
- throwErrorMessage.mockReturnValue('Error thrown');
-
- const result = await actions.delete({ commit }, 1);
-
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- deletingItem: true,
- });
- expect(throwErrorMessage).toHaveBeenCalledWith(error);
- expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
- deletingItem: false,
- });
- expect(result).toEqual('Error thrown');
- });
- });
- });
-
- describe('createStore', () => {
- it('creates a complete store with default options', () => {
- const API = {};
- const store = createStore({ name: 'test', API });
-
- expect(store.namespaced).toBe(true);
- expect(store.state).toEqual(createInitialState());
- expect(Object.keys(store.getters)).toEqual([
- 'getRecords',
- 'getRecord',
- 'getUIFlags',
- 'getMeta',
- ]);
- expect(Object.keys(store.mutations)).toEqual([
- 'SET_TEST_UI_FLAG',
- 'SET_TEST_META',
- 'SET_TEST',
- 'ADD_TEST',
- 'EDIT_TEST',
- 'DELETE_TEST',
- 'UPSERT_TEST',
- ]);
- expect(Object.keys(store.actions)).toEqual([
- 'get',
- 'show',
- 'create',
- 'update',
- 'delete',
- ]);
- });
-
- it('creates a store with custom actions and getters', () => {
- const API = {};
- const customGetters = { customGetter: () => 'custom' };
- const customActions = () => ({
- customAction: () => 'custom',
- });
-
- const store = createStore({
- name: 'test',
- API,
- getters: customGetters,
- actions: customActions,
- });
-
- expect(store.getters).toHaveProperty('customGetter');
- expect(store.actions).toHaveProperty('customAction');
- expect(Object.keys(store.getters)).toEqual([
- 'getRecords',
- 'getRecord',
- 'getUIFlags',
- 'getMeta',
- 'customGetter',
- ]);
- expect(Object.keys(store.actions)).toEqual([
- 'get',
- 'show',
- 'create',
- 'update',
- 'delete',
- 'customAction',
- ]);
- });
- });
-});
diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js
deleted file mode 100644
index ddb7367d7..000000000
--- a/app/javascript/dashboard/store/captain/storeFactoryHelper.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { throwErrorMessage } from 'dashboard/store/utils/api';
-
-export const getRecords =
- (mutationTypes, API) =>
- async ({ commit }, params = {}) => {
- commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
- try {
- const response = await API.get(params);
- commit(mutationTypes.SET, response.data.payload);
- commit(mutationTypes.SET_META, response.data.meta);
- return response.data.payload;
- } catch (error) {
- return throwErrorMessage(error);
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
- }
- };
-
-export const showRecord =
- (mutationTypes, API) =>
- async ({ commit }, id) => {
- commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
- try {
- const response = await API.show(id);
- commit(mutationTypes.UPSERT, response.data);
- return response.data;
- } catch (error) {
- return throwErrorMessage(error);
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
- }
- };
-
-export const createRecord =
- (mutationTypes, API) =>
- async ({ commit }, dataObj) => {
- commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
- try {
- const response = await API.create(dataObj);
- commit(mutationTypes.UPSERT, response.data);
- return response.data;
- } catch (error) {
- return throwErrorMessage(error);
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
- }
- };
-
-export const updateRecord =
- (mutationTypes, API) =>
- async ({ commit }, { id, ...updateObj }) => {
- commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
- try {
- const response = await API.update(id, updateObj);
- commit(mutationTypes.EDIT, response.data);
- return response.data;
- } catch (error) {
- return throwErrorMessage(error);
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
- }
- };
-
-export const deleteRecord =
- (mutationTypes, API) =>
- async ({ commit }, id) => {
- commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
- try {
- await API.delete(id);
- commit(mutationTypes.DELETE, id);
- return id;
- } catch (error) {
- return throwErrorMessage(error);
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
- }
- };
diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js
index 9638e45c3..b644caf5b 100644
--- a/app/javascript/dashboard/store/captain/tools.js
+++ b/app/javascript/dashboard/store/captain/tools.js
@@ -1,4 +1,4 @@
-import { createStore } from './storeFactory';
+import { createStore } from '../storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 705ce1c2e..d56958eb5 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -14,7 +14,6 @@ import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories';
-import companies from './modules/companies';
import contactConversations from './modules/contactConversations';
import contactLabels from './modules/contactLabels';
import contactNotes from './modules/contactNotes';
@@ -78,7 +77,6 @@ export default createStore({
campaigns,
cannedResponse,
categories,
- companies,
contactConversations,
contactLabels,
contactNotes,
diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js
index 1eef59cfe..44134e19d 100644
--- a/app/javascript/dashboard/store/modules/accounts.js
+++ b/app/javascript/dashboard/store/modules/accounts.js
@@ -18,6 +18,7 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
+ isFetchingLimits: false,
},
};
@@ -141,11 +142,14 @@ export const actions = {
},
limits: async ({ commit }) => {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
+ } finally {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
diff --git a/app/javascript/dashboard/store/modules/companies.js b/app/javascript/dashboard/store/modules/companies.js
deleted file mode 100644
index 116ed7e15..000000000
--- a/app/javascript/dashboard/store/modules/companies.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import CompanyAPI from 'dashboard/api/companies';
-import { createStore } from 'dashboard/store/captain/storeFactory';
-import camelcaseKeys from 'camelcase-keys';
-
-export default createStore({
- name: 'Company',
- API: CompanyAPI,
- getters: {
- getCompaniesList: state => {
- return camelcaseKeys(state.records, { deep: true });
- },
- },
- actions: mutationTypes => ({
- search: async ({ commit }, { search, page, sort }) => {
- commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
- try {
- const {
- data: { payload, meta },
- } = await CompanyAPI.search(search, page, sort);
- commit(mutationTypes.SET, payload);
- commit(mutationTypes.SET_META, meta);
- } catch (error) {
- // Error
- } finally {
- commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
- }
- },
- }),
-});
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index 02911d9d9..0f20a00d2 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -302,4 +302,22 @@ export const actions = {
clearContactFilters({ commit }) {
commit(types.CLEAR_CONTACT_FILTERS);
},
+
+ initiateCall: async ({ commit }, { contactId, inboxId }) => {
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
+ try {
+ const response = await ContactAPI.initiateCall(contactId, inboxId);
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
+ return response.data;
+ } catch (error) {
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
+ if (error.response?.data?.message) {
+ throw new ExceptionWithMessage(error.response.data.message);
+ } else if (error.response?.data?.error) {
+ throw new ExceptionWithMessage(error.response.data.error);
+ } else {
+ throw new Error(error);
+ }
+ }
+ },
};
diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js
index eb1e92434..c8b476e24 100644
--- a/app/javascript/dashboard/store/modules/contacts/index.js
+++ b/app/javascript/dashboard/store/modules/contacts/index.js
@@ -17,6 +17,7 @@ const state = {
isDeleting: false,
isExporting: false,
isImporting: false,
+ isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index d8ad826ca..dcaf5f9c9 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -12,6 +12,10 @@ import {
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
+import {
+ handleVoiceCallCreated,
+ handleVoiceCallUpdated,
+} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
@@ -299,7 +303,7 @@ const actions = {
}
},
- addMessage({ commit }, message) {
+ addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
@@ -308,10 +312,12 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
+ handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
},
- updateMessage({ commit }, message) {
+ updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
+ handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
},
deleteMessage: async function deleteLabels(
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 2ee6fd061..72b7bc566 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
+import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
const state = {
allConversations: [],
@@ -24,6 +25,10 @@ const state = {
copilotAssistant: {},
};
+const getConversationById = _state => conversationId => {
+ return _state.allConversations.find(c => c.id === conversationId);
+};
+
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
@@ -194,7 +199,6 @@ export const mutations = {
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
- emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@@ -225,7 +229,6 @@ export const mutations = {
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
- emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
@@ -272,6 +275,36 @@ export const mutations = {
}
},
+ [types.UPDATE_CONVERSATION_CALL_STATUS](
+ _state,
+ { conversationId, callStatus }
+ ) {
+ const chat = getConversationById(_state)(conversationId);
+ if (!chat) return;
+
+ chat.additional_attributes = {
+ ...chat.additional_attributes,
+ call_status: callStatus,
+ };
+ },
+
+ [types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
+ const chat = getConversationById(_state)(conversationId);
+ if (!chat) return;
+
+ const lastCall = (chat.messages || []).findLast(
+ m => m.content_type === CONTENT_TYPES.VOICE_CALL
+ );
+
+ if (!lastCall) return;
+
+ lastCall.content_attributes ??= {};
+ lastCall.content_attributes.data = {
+ ...lastCall.content_attributes.data,
+ status: callStatus,
+ };
+ },
+
[types.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
diff --git a/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js b/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js
new file mode 100644
index 000000000..09c89f0b0
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversations/specs/mutations.spec.js
@@ -0,0 +1,159 @@
+import { mutations } from '../index';
+import types from '../../../mutation-types';
+
+describe('#mutations', () => {
+ describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
+ it('does nothing if conversation is not found', () => {
+ const state = { allConversations: [] };
+ mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'ringing',
+ });
+ expect(state.allConversations).toEqual([]);
+ });
+
+ it('updates call_status preserving existing additional_attributes', () => {
+ const state = {
+ allConversations: [
+ { id: 1, additional_attributes: { other_attr: 'value' } },
+ ],
+ };
+ mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'in-progress',
+ });
+ expect(state.allConversations[0].additional_attributes).toEqual({
+ other_attr: 'value',
+ call_status: 'in-progress',
+ });
+ });
+
+ it('creates additional_attributes if it does not exist', () => {
+ const state = { allConversations: [{ id: 1 }] };
+ mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'completed',
+ });
+ expect(state.allConversations[0].additional_attributes).toEqual({
+ call_status: 'completed',
+ });
+ });
+ });
+
+ describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
+ it('does nothing if conversation is not found', () => {
+ const state = { allConversations: [] };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'ringing',
+ });
+ expect(state.allConversations).toEqual([]);
+ });
+
+ it('does nothing if no voice call message exists', () => {
+ const state = {
+ allConversations: [
+ { id: 1, messages: [{ id: 1, content_type: 'text' }] },
+ ],
+ };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'ringing',
+ });
+ expect(state.allConversations[0].messages[0]).toEqual({
+ id: 1,
+ content_type: 'text',
+ });
+ });
+
+ it('updates the last voice call message status', () => {
+ const state = {
+ allConversations: [
+ {
+ id: 1,
+ messages: [
+ {
+ id: 1,
+ content_type: 'voice_call',
+ content_attributes: { data: { status: 'ringing' } },
+ },
+ {
+ id: 2,
+ content_type: 'voice_call',
+ content_attributes: { data: { status: 'ringing' } },
+ },
+ ],
+ },
+ ],
+ };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'in-progress',
+ });
+ expect(
+ state.allConversations[0].messages[0].content_attributes.data.status
+ ).toBe('ringing');
+ expect(
+ state.allConversations[0].messages[1].content_attributes.data.status
+ ).toBe('in-progress');
+ });
+
+ it('creates content_attributes.data if it does not exist', () => {
+ const state = {
+ allConversations: [
+ {
+ id: 1,
+ messages: [{ id: 1, content_type: 'voice_call' }],
+ },
+ ],
+ };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'completed',
+ });
+ expect(
+ state.allConversations[0].messages[0].content_attributes.data.status
+ ).toBe('completed');
+ });
+
+ it('preserves existing data in content_attributes.data', () => {
+ const state = {
+ allConversations: [
+ {
+ id: 1,
+ messages: [
+ {
+ id: 1,
+ content_type: 'voice_call',
+ content_attributes: {
+ data: { call_sid: 'CA123', status: 'ringing' },
+ },
+ },
+ ],
+ },
+ ],
+ };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'in-progress',
+ });
+ expect(
+ state.allConversations[0].messages[0].content_attributes.data
+ ).toEqual({
+ call_sid: 'CA123',
+ status: 'in-progress',
+ });
+ });
+
+ it('handles empty messages array', () => {
+ const state = {
+ allConversations: [{ id: 1, messages: [] }],
+ };
+ mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
+ conversationId: 1,
+ callStatus: 'ringing',
+ });
+ expect(state.allConversations[0].messages).toEqual([]);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 4c4e30d3a..06c144103 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -165,6 +165,13 @@ export const getters = {
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
+ getTiktokInboxByBusinessId: $state => businessId => {
+ return $state.records.find(
+ item =>
+ item.business_id === businessId &&
+ item.channel_type === INBOX_TYPES.TIKTOK
+ );
+ },
};
const sendAnalyticsEvent = channelType => {
diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
index 79852a42e..00b7bb83b 100644
--- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
@@ -359,4 +359,83 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
+
+ describe('#initiateCall', () => {
+ const contactId = 123;
+ const inboxId = 456;
+
+ it('sends correct mutations if API is success', async () => {
+ const mockResponse = {
+ data: {
+ conversation_id: 789,
+ status: 'initiated',
+ },
+ };
+ axios.post.mockResolvedValue(mockResponse);
+
+ const result = await actions.initiateCall(
+ { commit },
+ { contactId, inboxId }
+ );
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ expect(result).toEqual(mockResponse.data);
+ });
+
+ it('sends correct actions if API returns error with message', async () => {
+ const errorMessage = 'Failed to initiate call';
+ axios.post.mockRejectedValue({
+ response: {
+ data: {
+ message: errorMessage,
+ },
+ },
+ });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(ExceptionWithMessage);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+
+ it('sends correct actions if API returns error with error field', async () => {
+ const errorMessage = 'Call initiation error';
+ axios.post.mockRejectedValue({
+ response: {
+ data: {
+ error: errorMessage,
+ },
+ },
+ });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(ExceptionWithMessage);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+
+ it('sends correct actions if API returns generic error', async () => {
+ axios.post.mockRejectedValue({ message: 'Network error' });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
index b8660b20d..48e676215 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
@@ -753,7 +753,6 @@ describe('#mutations', () => {
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
- expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
index 382df4828..cc2d6bbf3 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
@@ -71,4 +71,12 @@ export default [
instagram_id: 123456789,
provider: 'default',
},
+ {
+ id: 8,
+ channel_id: 8,
+ name: 'Test TikTok 1',
+ channel_type: 'Channel::Tiktok',
+ business_id: 123456789,
+ provider: 'default',
+ },
];
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
index ac2aab26b..5885836ed 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
@@ -27,7 +27,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
- expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
+ expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(8);
});
it('getInbox', () => {
@@ -95,6 +95,18 @@ describe('#getters', () => {
});
});
+ it('getTiktokInboxByBusinessId', () => {
+ const state = { records: inboxList };
+ expect(getters.getTiktokInboxByBusinessId(state)(123456789)).toEqual({
+ id: 8,
+ channel_id: 8,
+ name: 'Test TikTok 1',
+ channel_type: 'Channel::Tiktok',
+ business_id: 123456789,
+ provider: 'default',
+ });
+ });
+
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 68ff79e66..48e6babdc 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -51,6 +51,8 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
+ UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
+ UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
diff --git a/app/javascript/dashboard/store/storeFactory.js b/app/javascript/dashboard/store/storeFactory.js
new file mode 100644
index 000000000..a623b4785
--- /dev/null
+++ b/app/javascript/dashboard/store/storeFactory.js
@@ -0,0 +1,226 @@
+/**
+ * Universal Store Factory
+ *
+ * This factory creates stores for both Vuex and Pinia, allowing gradual
+ * migration from Vuex to Pinia without breaking existing functionality.
+ *
+ * @module storeFactory
+ * @see https://pinia.vuejs.org/ - Pinia documentation
+ * @see https://vuex.vuejs.org/ - Vuex documentation
+ */
+
+import { defineStore } from 'pinia';
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+import {
+ // Vuex helpers
+ createRecord,
+ deleteRecord,
+ getRecords,
+ showRecord,
+ updateRecord,
+ // Pinia helpers
+ piniaGetRecords,
+ piniaShowRecord,
+ piniaCreateRecord,
+ piniaUpdateRecord,
+ piniaDeleteRecord,
+} from './storeFactoryHelper';
+
+export const generateMutationTypes = name => {
+ const capitalizedName = name.toUpperCase();
+ return {
+ SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
+ SET: `SET_${capitalizedName}`,
+ ADD: `ADD_${capitalizedName}`,
+ EDIT: `EDIT_${capitalizedName}`,
+ DELETE: `DELETE_${capitalizedName}`,
+ SET_META: `SET_${capitalizedName}_META`,
+ UPSERT: `UPSERT_${capitalizedName}`,
+ };
+};
+
+export const createInitialState = () => ({
+ records: [],
+ meta: {},
+ uiFlags: {
+ fetchingList: false,
+ fetchingItem: false,
+ creatingItem: false,
+ updatingItem: false,
+ deletingItem: false,
+ },
+});
+
+export const createGetters = () => ({
+ getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
+ getRecord: state => id =>
+ state.records.find(record => record.id === Number(id)) || {},
+ getUIFlags: state => state.uiFlags,
+ getMeta: state => state.meta,
+});
+
+export const createMutations = mutationTypes => ({
+ [mutationTypes.SET_UI_FLAG](state, data) {
+ state.uiFlags = {
+ ...state.uiFlags,
+ ...data,
+ };
+ },
+ [mutationTypes.SET_META](state, meta) {
+ state.meta = {
+ ...state.meta,
+ totalCount: Number(meta.total_count),
+ page: Number(meta.page),
+ };
+ },
+ [mutationTypes.SET]: MutationHelpers.set,
+ [mutationTypes.ADD]: MutationHelpers.create,
+ [mutationTypes.EDIT]: MutationHelpers.update,
+ [mutationTypes.DELETE]: MutationHelpers.destroy,
+ [mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
+});
+
+export const createCrudActions = (API, mutationTypes) => ({
+ get: getRecords(mutationTypes, API),
+ show: showRecord(mutationTypes, API),
+ create: createRecord(mutationTypes, API),
+ update: updateRecord(mutationTypes, API),
+ delete: deleteRecord(mutationTypes, API),
+});
+
+/**
+ * Create Vuex store with standard CRUD operations
+ *
+ * @param {Object} options - Store configuration
+ * @param {string} options.name - Store name
+ * @param {Object} options.API - API client
+ * @param {Object} [options.getters] - Custom getters
+ * @param {Function} [options.actions] - Custom actions function
+ * @param {Object} [options.mutations] - Custom mutations
+ * @returns {Object} Vuex module configuration
+ */
+export const createVuexStore = options => {
+ const { name, API, actions, getters, mutations } = options;
+
+ const mutationTypes = generateMutationTypes(name);
+ const customActions = actions ? actions(mutationTypes) : {};
+
+ return {
+ namespaced: true,
+ state: createInitialState(),
+ getters: {
+ ...createGetters(),
+ ...(getters || {}),
+ },
+ mutations: {
+ ...createMutations(mutationTypes),
+ ...(mutations || {}),
+ },
+ actions: {
+ ...createCrudActions(API, mutationTypes),
+ ...customActions,
+ },
+ };
+};
+
+/**
+ * Create Pinia store with standard CRUD operations
+ *
+ * @param {Object} options - Store configuration
+ * @param {string} options.name - Store name
+ * @param {Object} options.API - API client
+ * @param {Object} [options.getters] - Custom getters
+ * @param {Function} [options.actions] - Custom actions function
+ * @returns {Function} Pinia store composable
+ */
+export const createPiniaStore = options => {
+ const { name, API, actions, getters } = options;
+
+ return defineStore(name.toLowerCase(), {
+ state: createInitialState,
+
+ getters: {
+ ...createGetters(),
+ ...(getters || {}),
+ },
+
+ actions: {
+ setUIFlag(data) {
+ this.uiFlags = {
+ ...this.uiFlags,
+ ...data,
+ };
+ },
+
+ setMeta(meta) {
+ this.meta = {
+ ...this.meta,
+ totalCount: Number(meta.total_count || meta.totalCount || 0),
+ page: Number(meta.page || 1),
+ };
+ },
+
+ async get(params) {
+ return piniaGetRecords(this, API, params);
+ },
+
+ async show(id) {
+ return piniaShowRecord(this, API, id);
+ },
+
+ async create(obj) {
+ return piniaCreateRecord(this, API, obj);
+ },
+
+ async update(payload) {
+ return piniaUpdateRecord(this, API, payload);
+ },
+
+ async delete(id) {
+ return piniaDeleteRecord(this, API, id);
+ },
+
+ ...(actions ? actions() : {}),
+ },
+ });
+};
+
+/**
+ * Universal Store Factory - Main Entry Point
+ *
+ * Creates either a Vuex or Pinia store based on the 'type' parameter.
+ * Defaults to Vuex for backward compatibility.
+ *
+ * @param {Object} options - Store configuration
+ * @param {string} options.name - Store name
+ * @param {Object} options.API - API client for CRUD operations
+ * @param {string} [options.type='vuex'] - Store type: 'vuex' or 'pinia'
+ * @param {Object} [options.getters] - Custom getters
+ * @param {Function} [options.actions] - Custom actions function
+ * @param {Object} [options.mutations] - Custom mutations (Vuex only)
+ *
+ * @returns {Object|Function} Vuex module or Pinia store composable
+ *
+ * @example
+ * Create Vuex store (default)
+ * export default createStore({
+ * name: 'Company',
+ * API: CompanyAPI,
+ * });
+ *
+ * @example
+ * Create Pinia store
+ * export const useCompaniesStore = createStore({
+ * name: 'Company',
+ * type: 'pinia',
+ * API: CompanyAPI,
+ * });
+ */
+export const createStore = options => {
+ const { type = 'vuex' } = options;
+
+ if (type === 'pinia') {
+ return createPiniaStore(options);
+ }
+ return createVuexStore(options);
+};
diff --git a/app/javascript/dashboard/store/storeFactory.spec.js b/app/javascript/dashboard/store/storeFactory.spec.js
new file mode 100644
index 000000000..3dc2eedd7
--- /dev/null
+++ b/app/javascript/dashboard/store/storeFactory.spec.js
@@ -0,0 +1,761 @@
+import { setActivePinia, createPinia } from 'pinia';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+import {
+ generateMutationTypes,
+ createInitialState,
+ createGetters,
+ createMutations,
+ createCrudActions,
+ createStore,
+} from './storeFactory';
+
+vi.mock('dashboard/store/utils/api', () => ({
+ throwErrorMessage: vi.fn(),
+}));
+
+vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
+ set: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ destroy: vi.fn(),
+ setSingleRecord: vi.fn(),
+}));
+
+describe('storeFactory', () => {
+ describe('createStore with type parameter', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it('creates Vuex store by default', () => {
+ const API = {};
+ const store = createStore({ name: 'test', API });
+
+ expect(store.namespaced).toBe(true);
+ expect(store.state).toBeDefined();
+ expect(store.mutations).toBeDefined();
+ });
+
+ it('creates Pinia store when type is "pinia"', () => {
+ const API = {
+ get: vi.fn(),
+ show: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ };
+
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ });
+
+ const store = useTestStore();
+
+ expect(store.records).toBeDefined();
+ expect(store.get).toBeTypeOf('function');
+ expect(store.setUIFlag).toBeTypeOf('function');
+ });
+
+ it('creates Vuex store when type is "vuex"', () => {
+ const API = {};
+ const store = createStore({
+ name: 'test',
+ API,
+ type: 'vuex',
+ });
+
+ expect(store.namespaced).toBe(true);
+ expect(store.state).toBeDefined();
+ });
+ });
+
+ describe('generateMutationTypes', () => {
+ it('generates correct mutation types with capitalized name', () => {
+ const result = generateMutationTypes('test');
+ expect(result).toEqual({
+ SET_UI_FLAG: 'SET_TEST_UI_FLAG',
+ SET: 'SET_TEST',
+ ADD: 'ADD_TEST',
+ EDIT: 'EDIT_TEST',
+ DELETE: 'DELETE_TEST',
+ SET_META: 'SET_TEST_META',
+ UPSERT: 'UPSERT_TEST',
+ });
+ });
+ });
+
+ describe('createInitialState', () => {
+ it('returns the correct initial state structure', () => {
+ const result = createInitialState();
+ expect(result).toEqual({
+ records: [],
+ meta: {},
+ uiFlags: {
+ fetchingList: false,
+ fetchingItem: false,
+ creatingItem: false,
+ updatingItem: false,
+ deletingItem: false,
+ },
+ });
+ });
+ });
+
+ describe('createGetters', () => {
+ it('returns getters with correct implementations', () => {
+ const getters = createGetters();
+
+ const state = {
+ records: [{ id: 2 }, { id: 1 }, { id: 3 }],
+ uiFlags: { fetchingList: true },
+ meta: { totalCount: 10, page: 1 },
+ };
+ expect(getters.getRecords(state)).toEqual([
+ { id: 3 },
+ { id: 2 },
+ { id: 1 },
+ ]);
+
+ expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
+ expect(getters.getRecord(state)(4)).toEqual({});
+
+ expect(getters.getUIFlags(state)).toEqual({
+ fetchingList: true,
+ });
+
+ expect(getters.getMeta(state)).toEqual({
+ totalCount: 10,
+ page: 1,
+ });
+ });
+ });
+
+ describe('createMutations', () => {
+ it('creates mutations with correct implementations', () => {
+ const mutationTypes = generateMutationTypes('test');
+ const mutations = createMutations(mutationTypes);
+
+ const state = { uiFlags: { fetchingList: false } };
+ mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
+ expect(state.uiFlags).toEqual({ fetchingList: true });
+
+ const metaState = { meta: {} };
+ mutations[mutationTypes.SET_META](metaState, {
+ total_count: '10',
+ page: '2',
+ });
+ expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
+
+ expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
+ expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
+ expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
+ expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
+ expect(mutations[mutationTypes.UPSERT]).toBe(
+ MutationHelpers.setSingleRecord
+ );
+ });
+ });
+
+ describe('createCrudActions', () => {
+ let API;
+ let commit;
+ let mutationTypes;
+ let actions;
+
+ beforeEach(() => {
+ API = {
+ get: vi.fn(),
+ show: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ };
+ commit = vi.fn();
+ mutationTypes = generateMutationTypes('test');
+ actions = createCrudActions(API, mutationTypes);
+ });
+
+ describe('get action', () => {
+ it('handles successful API response', async () => {
+ const payload = [{ id: 1 }];
+ const meta = { total_count: 10, page: 1 };
+ API.get.mockResolvedValue({ data: { payload, meta } });
+
+ const result = await actions.get({ commit });
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingList: true,
+ });
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingList: false,
+ });
+ expect(result).toEqual(payload);
+ });
+
+ it('handles API error', async () => {
+ const error = new Error('API Error');
+ API.get.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const result = await actions.get({ commit });
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingList: true,
+ });
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingList: false,
+ });
+ expect(result).toEqual('Error thrown');
+ });
+ });
+
+ describe('show action', () => {
+ it('handles successful API response', async () => {
+ const data = { id: 1, name: 'Test' };
+ API.show.mockResolvedValue({ data });
+
+ const result = await actions.show({ commit }, 1);
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingItem: true,
+ });
+ expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingItem: false,
+ });
+ expect(result).toEqual(data);
+ });
+
+ it('handles API error', async () => {
+ const error = new Error('API Error');
+ API.show.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const result = await actions.show({ commit }, 1);
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingItem: true,
+ });
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ fetchingItem: false,
+ });
+ expect(result).toEqual('Error thrown');
+ });
+ });
+
+ describe('create action', () => {
+ it('handles successful API response', async () => {
+ const data = { id: 1, name: 'Test' };
+ API.create.mockResolvedValue({ data });
+
+ const result = await actions.create({ commit }, { name: 'Test' });
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ creatingItem: true,
+ });
+ expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ creatingItem: false,
+ });
+ expect(result).toEqual(data);
+ });
+
+ it('handles API error', async () => {
+ const error = new Error('API Error');
+ API.create.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const result = await actions.create({ commit }, { name: 'Test' });
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ creatingItem: true,
+ });
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ creatingItem: false,
+ });
+ expect(result).toEqual('Error thrown');
+ });
+ });
+
+ describe('update action', () => {
+ it('handles successful API response', async () => {
+ const data = { id: 1, name: 'Updated' };
+ API.update.mockResolvedValue({ data });
+
+ const result = await actions.update(
+ { commit },
+ { id: 1, name: 'Updated' }
+ );
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ updatingItem: true,
+ });
+ expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
+ expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ updatingItem: false,
+ });
+ expect(result).toEqual(data);
+ });
+
+ it('handles API error', async () => {
+ const error = new Error('API Error');
+ API.update.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const result = await actions.update(
+ { commit },
+ { id: 1, name: 'Updated' }
+ );
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ updatingItem: true,
+ });
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ updatingItem: false,
+ });
+ expect(result).toEqual('Error thrown');
+ });
+ });
+
+ describe('delete action', () => {
+ it('handles successful API response', async () => {
+ API.delete.mockResolvedValue({});
+
+ const result = await actions.delete({ commit }, 1);
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ deletingItem: true,
+ });
+ expect(API.delete).toHaveBeenCalledWith(1);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ deletingItem: false,
+ });
+ expect(result).toEqual(1);
+ });
+
+ it('handles API error', async () => {
+ const error = new Error('API Error');
+ API.delete.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const result = await actions.delete({ commit }, 1);
+
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ deletingItem: true,
+ });
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
+ deletingItem: false,
+ });
+ expect(result).toEqual('Error thrown');
+ });
+ });
+ });
+
+ describe('createStore - Vuex type', () => {
+ it('creates a complete Vuex store with default options', () => {
+ const API = {};
+ const store = createStore({ name: 'test', API, type: 'vuex' });
+
+ expect(store.namespaced).toBe(true);
+ expect(store.state).toEqual(createInitialState());
+ expect(Object.keys(store.getters)).toEqual([
+ 'getRecords',
+ 'getRecord',
+ 'getUIFlags',
+ 'getMeta',
+ ]);
+ expect(Object.keys(store.mutations)).toEqual([
+ 'SET_TEST_UI_FLAG',
+ 'SET_TEST_META',
+ 'SET_TEST',
+ 'ADD_TEST',
+ 'EDIT_TEST',
+ 'DELETE_TEST',
+ 'UPSERT_TEST',
+ ]);
+ expect(Object.keys(store.actions)).toEqual([
+ 'get',
+ 'show',
+ 'create',
+ 'update',
+ 'delete',
+ ]);
+ });
+
+ it('creates a Vuex store with custom actions and getters', () => {
+ const API = {};
+ const customGetters = { customGetter: () => 'custom' };
+ const customActions = () => ({
+ customAction: () => 'custom',
+ });
+
+ const store = createStore({
+ name: 'test',
+ API,
+ type: 'vuex',
+ getters: customGetters,
+ actions: customActions,
+ });
+
+ expect(store.getters).toHaveProperty('customGetter');
+ expect(store.actions).toHaveProperty('customAction');
+ expect(Object.keys(store.getters)).toEqual([
+ 'getRecords',
+ 'getRecord',
+ 'getUIFlags',
+ 'getMeta',
+ 'customGetter',
+ ]);
+ expect(Object.keys(store.actions)).toEqual([
+ 'get',
+ 'show',
+ 'create',
+ 'update',
+ 'delete',
+ 'customAction',
+ ]);
+ });
+
+ it('creates a Vuex store with custom mutations', () => {
+ const API = {};
+ const customMutations = {
+ CUSTOM_MUTATION: state => {
+ state.custom = true;
+ },
+ };
+
+ const store = createStore({
+ name: 'test',
+ API,
+ type: 'vuex',
+ mutations: customMutations,
+ });
+
+ expect(store.mutations).toHaveProperty('CUSTOM_MUTATION');
+ });
+ });
+
+ describe('createStore - Pinia type', () => {
+ let API;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ API = {
+ get: vi.fn(),
+ show: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ };
+ });
+
+ it('creates a Pinia store with correct structure', () => {
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ });
+
+ const store = useTestStore();
+
+ expect(store.records).toEqual([]);
+ expect(store.meta).toEqual({});
+ expect(store.uiFlags).toEqual({
+ fetchingList: false,
+ fetchingItem: false,
+ creatingItem: false,
+ updatingItem: false,
+ deletingItem: false,
+ });
+ });
+
+ it('has standard getters', () => {
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ });
+
+ const store = useTestStore();
+ store.records = [{ id: 2 }, { id: 1 }, { id: 3 }];
+ store.meta = { totalCount: 10, page: 1 };
+ store.uiFlags = { fetchingList: true };
+
+ expect(store.getRecords).toEqual([{ id: 3 }, { id: 2 }, { id: 1 }]);
+ expect(store.getRecord(2)).toEqual({ id: 2 });
+ expect(store.getRecord(4)).toEqual({});
+ expect(store.getUIFlags).toEqual({ fetchingList: true });
+ expect(store.getMeta).toEqual({ totalCount: 10, page: 1 });
+ });
+
+ it('has custom getters', () => {
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ getters: {
+ customGetter: state => state.records.length,
+ },
+ });
+
+ const store = useTestStore();
+ store.records = [{ id: 1 }, { id: 2 }];
+
+ expect(store.customGetter).toBe(2);
+ });
+
+ describe('setUIFlag action', () => {
+ it('updates UI flags correctly', () => {
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ store.setUIFlag({ fetchingList: true });
+ expect(store.uiFlags.fetchingList).toBe(true);
+
+ store.setUIFlag({ creatingItem: true });
+ expect(store.uiFlags.fetchingList).toBe(true);
+ expect(store.uiFlags.creatingItem).toBe(true);
+ });
+ });
+
+ describe('setMeta action', () => {
+ it('updates meta with snake_case input', () => {
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ store.setMeta({ total_count: '10', page: '2' });
+ expect(store.meta.totalCount).toBe(10);
+ expect(store.meta.page).toBe(2);
+ });
+
+ it('updates meta with camelCase input', () => {
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ store.setMeta({ totalCount: 15, page: 3 });
+ expect(store.meta.totalCount).toBe(15);
+ expect(store.meta.page).toBe(3);
+ });
+ });
+
+ describe('get action', () => {
+ it('fetches records successfully', async () => {
+ const payload = [{ id: 1, name: 'Test' }];
+ const meta = { total_count: 1, page: 1 };
+ API.get.mockResolvedValue({ data: { payload, meta } });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ await store.get({ page: 1 });
+
+ expect(API.get).toHaveBeenCalledWith({ page: 1 });
+ expect(store.records).toEqual(payload);
+ expect(store.meta.totalCount).toBe(1);
+ expect(store.meta.page).toBe(1);
+ expect(store.uiFlags.fetchingList).toBe(false);
+ });
+
+ it('handles API response without meta', async () => {
+ const data = [{ id: 1, name: 'Test' }];
+ API.get.mockResolvedValue({ data });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ await store.get();
+
+ expect(store.records).toEqual(data);
+ });
+
+ it('handles errors and resets UI flags', async () => {
+ const error = new Error('API Error');
+ API.get.mockRejectedValue(error);
+ throwErrorMessage.mockReturnValue('Error thrown');
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ const result = await store.get();
+
+ expect(throwErrorMessage).toHaveBeenCalledWith(error);
+ expect(store.uiFlags.fetchingList).toBe(false);
+ expect(result).toBe('Error thrown');
+ });
+ });
+
+ describe('show action', () => {
+ it('fetches and upserts a record', async () => {
+ const data = { id: 1, name: 'Test' };
+ API.show.mockResolvedValue({ data });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ const result = await store.show(1);
+
+ expect(API.show).toHaveBeenCalledWith(1);
+ expect(store.records).toContainEqual(data);
+ expect(result).toEqual(data);
+ expect(store.uiFlags.fetchingItem).toBe(false);
+ });
+
+ it('updates existing record', async () => {
+ const data = { id: 1, name: 'Updated' };
+ API.show.mockResolvedValue({ data });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+ store.records = [{ id: 1, name: 'Original' }];
+
+ await store.show(1);
+
+ expect(store.records).toHaveLength(1);
+ expect(store.records[0].name).toBe('Updated');
+ });
+
+ it('handles payload wrapper', async () => {
+ const record = { id: 1, name: 'Test' };
+ API.show.mockResolvedValue({ data: { payload: record } });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ const result = await store.show(1);
+
+ expect(result).toEqual(record);
+ expect(store.records).toContainEqual(record);
+ });
+ });
+
+ describe('create action', () => {
+ it('creates a new record', async () => {
+ const data = { id: 1, name: 'New' };
+ API.create.mockResolvedValue({ data });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ const result = await store.create({ name: 'New' });
+
+ expect(API.create).toHaveBeenCalledWith({ name: 'New' });
+ expect(store.records).toContainEqual(data);
+ expect(result).toEqual(data);
+ expect(store.uiFlags.creatingItem).toBe(false);
+ });
+
+ it('handles payload wrapper', async () => {
+ const record = { id: 1, name: 'New' };
+ API.create.mockResolvedValue({ data: { payload: record } });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+
+ const result = await store.create({ name: 'New' });
+
+ expect(result).toEqual(record);
+ expect(store.records).toContainEqual(record);
+ });
+ });
+
+ describe('update action', () => {
+ it('updates an existing record', async () => {
+ const data = { id: 1, name: 'Updated' };
+ API.update.mockResolvedValue({ data });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+ store.records = [{ id: 1, name: 'Original' }];
+
+ const result = await store.update({ id: 1, name: 'Updated' });
+
+ expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
+ expect(store.records[0].name).toBe('Updated');
+ expect(result).toEqual(data);
+ expect(store.uiFlags.updatingItem).toBe(false);
+ });
+
+ it('handles payload wrapper', async () => {
+ const record = { id: 1, name: 'Updated' };
+ API.update.mockResolvedValue({ data: { payload: record } });
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+ store.records = [{ id: 1, name: 'Original' }];
+
+ const result = await store.update({ id: 1, name: 'Updated' });
+
+ expect(result).toEqual(record);
+ expect(store.records[0]).toEqual(record);
+ });
+ });
+
+ describe('delete action', () => {
+ it('deletes a record', async () => {
+ API.delete.mockResolvedValue({});
+
+ const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
+ const store = useTestStore();
+ store.records = [{ id: 1 }, { id: 2 }, { id: 3 }];
+
+ const result = await store.delete(2);
+
+ expect(API.delete).toHaveBeenCalledWith(2);
+ expect(store.records).toHaveLength(2);
+ expect(store.records).not.toContainEqual({ id: 2 });
+ expect(result).toBe(2);
+ expect(store.uiFlags.deletingItem).toBe(false);
+ });
+ });
+
+ describe('custom actions', () => {
+ it('includes custom actions', async () => {
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ actions: () => ({
+ customAction() {
+ return 'custom result';
+ },
+ }),
+ });
+
+ const store = useTestStore();
+ const result = store.customAction();
+
+ expect(result).toBe('custom result');
+ });
+
+ it('custom actions can access store state', () => {
+ const useTestStore = createStore({
+ name: 'test',
+ API,
+ type: 'pinia',
+ actions: () => ({
+ getRecordCount() {
+ return this.records.length;
+ },
+ }),
+ });
+
+ const store = useTestStore();
+ store.records = [{ id: 1 }, { id: 2 }];
+
+ expect(store.getRecordCount()).toBe(2);
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/storeFactoryHelper.js b/app/javascript/dashboard/store/storeFactoryHelper.js
new file mode 100644
index 000000000..f87be165a
--- /dev/null
+++ b/app/javascript/dashboard/store/storeFactoryHelper.js
@@ -0,0 +1,203 @@
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+
+// ============================================================================
+// VUEX HELPERS
+// ============================================================================
+
+export const getRecords =
+ (mutationTypes, API) =>
+ async ({ commit }, params = {}) => {
+ commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
+ try {
+ const response = await API.get(params);
+ commit(mutationTypes.SET, response.data.payload);
+ commit(mutationTypes.SET_META, response.data.meta);
+ return response.data.payload;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
+ }
+ };
+
+export const showRecord =
+ (mutationTypes, API) =>
+ async ({ commit }, id) => {
+ commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
+ try {
+ const response = await API.show(id);
+ commit(mutationTypes.UPSERT, response.data);
+ return response.data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
+ }
+ };
+
+export const createRecord =
+ (mutationTypes, API) =>
+ async ({ commit }, dataObj) => {
+ commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
+ try {
+ const response = await API.create(dataObj);
+ commit(mutationTypes.UPSERT, response.data);
+ return response.data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
+ }
+ };
+
+export const updateRecord =
+ (mutationTypes, API) =>
+ async ({ commit }, { id, ...updateObj }) => {
+ commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
+ try {
+ const response = await API.update(id, updateObj);
+ commit(mutationTypes.EDIT, response.data);
+ return response.data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
+ }
+ };
+
+export const deleteRecord =
+ (mutationTypes, API) =>
+ async ({ commit }, id) => {
+ commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
+ try {
+ await API.delete(id);
+ commit(mutationTypes.DELETE, id);
+ return id;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
+ }
+ };
+
+// ============================================================================
+// PINIA HELPERS
+// ============================================================================
+
+/**
+ * Get records from API and update Pinia store
+ * @param {Object} store - Pinia store instance (this context)
+ * @param {Object} API - API client
+ * @param {Object} params - Query parameters
+ */
+export const piniaGetRecords = async (store, API, params = {}) => {
+ store.setUIFlag({ fetchingList: true });
+ try {
+ const response = await API.get(params);
+ const { data } = response;
+ store.records = data.payload || data;
+ if (data.meta) {
+ store.setMeta(data.meta);
+ }
+ return data.payload || data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ store.setUIFlag({ fetchingList: false });
+ }
+};
+
+/**
+ * Show single record from API and upsert to Pinia store
+ * @param {Object} store - Pinia store instance (this context)
+ * @param {Object} API - API client
+ * @param {Number|String} id - Record ID
+ */
+export const piniaShowRecord = async (store, API, id) => {
+ store.setUIFlag({ fetchingItem: true });
+ try {
+ const response = await API.show(id);
+ const { data } = response;
+ const record = data.payload || data;
+
+ // Upsert logic
+ const index = store.records.findIndex(r => r.id === record.id);
+ if (index !== -1) {
+ store.records[index] = record;
+ } else {
+ store.records.push(record);
+ }
+
+ return record;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ store.setUIFlag({ fetchingItem: false });
+ }
+};
+
+/**
+ * Create new record via API and add to Pinia store
+ * @param {Object} store - Pinia store instance (this context)
+ * @param {Object} API - API client
+ * @param {Object} dataObj - Data to create
+ */
+export const piniaCreateRecord = async (store, API, dataObj) => {
+ store.setUIFlag({ creatingItem: true });
+ try {
+ const response = await API.create(dataObj);
+ const { data } = response;
+ const record = data.payload || data;
+ store.records.push(record);
+ return record;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ store.setUIFlag({ creatingItem: false });
+ }
+};
+
+/**
+ * Update existing record via API and update in Pinia store
+ * @param {Object} store - Pinia store instance (this context)
+ * @param {Object} API - API client
+ * @param {Object} payload - Update payload with id
+ */
+export const piniaUpdateRecord = async (store, API, { id, ...updateObj }) => {
+ store.setUIFlag({ updatingItem: true });
+ try {
+ const response = await API.update(id, updateObj);
+ const { data } = response;
+ const record = data.payload || data;
+
+ const index = store.records.findIndex(r => r.id === record.id);
+ if (index !== -1) {
+ store.records[index] = record;
+ }
+
+ return record;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ store.setUIFlag({ updatingItem: false });
+ }
+};
+
+/**
+ * Delete record via API and remove from Pinia store
+ * @param {Object} store - Pinia store instance (this context)
+ * @param {Object} API - API client
+ * @param {Number|String} id - Record ID to delete
+ */
+export const piniaDeleteRecord = async (store, API, id) => {
+ store.setUIFlag({ deletingItem: true });
+ try {
+ await API.delete(id);
+ store.records = store.records.filter(record => record.id !== id);
+ return id;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ store.setUIFlag({ deletingItem: false });
+ }
+};
diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js
new file mode 100644
index 000000000..a153f158d
--- /dev/null
+++ b/app/javascript/dashboard/stores/calls.js
@@ -0,0 +1,59 @@
+import { defineStore } from 'pinia';
+import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
+import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
+
+export const useCallsStore = defineStore('calls', {
+ state: () => ({
+ calls: [],
+ }),
+
+ getters: {
+ activeCall: state => state.calls.find(call => call.isActive) || null,
+ hasActiveCall: state => state.calls.some(call => call.isActive),
+ incomingCalls: state => state.calls.filter(call => !call.isActive),
+ hasIncomingCall: state => state.calls.some(call => !call.isActive),
+ },
+
+ actions: {
+ handleCallStatusChanged({ callSid, status }) {
+ if (TERMINAL_STATUSES.includes(status)) {
+ this.removeCall(callSid);
+ }
+ },
+
+ addCall(callData) {
+ if (!callData?.callSid) return;
+ const exists = this.calls.some(call => call.callSid === callData.callSid);
+ if (exists) return;
+
+ this.calls.push({
+ ...callData,
+ isActive: false,
+ });
+ },
+
+ removeCall(callSid) {
+ const callToRemove = this.calls.find(c => c.callSid === callSid);
+ if (callToRemove?.isActive) {
+ TwilioVoiceClient.endClientCall();
+ }
+ this.calls = this.calls.filter(c => c.callSid !== callSid);
+ },
+
+ setCallActive(callSid) {
+ this.calls = this.calls.map(call => ({
+ ...call,
+ isActive: call.callSid === callSid,
+ }));
+ },
+
+ clearActiveCall() {
+ TwilioVoiceClient.endClientCall();
+ this.calls = this.calls.filter(call => !call.isActive);
+ },
+
+ dismissCall(callSid) {
+ this.calls = this.calls.filter(call => call.callSid !== callSid);
+ },
+ },
+});
diff --git a/app/javascript/dashboard/stores/companies.js b/app/javascript/dashboard/stores/companies.js
new file mode 100644
index 000000000..d1e8e56e9
--- /dev/null
+++ b/app/javascript/dashboard/stores/companies.js
@@ -0,0 +1,31 @@
+import CompanyAPI from 'dashboard/api/companies';
+import { createStore } from 'dashboard/store/storeFactory';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+import camelcaseKeys from 'camelcase-keys';
+
+export const useCompaniesStore = createStore({
+ name: 'companies',
+ type: 'pinia',
+ API: CompanyAPI,
+ getters: {
+ getCompaniesList: state => {
+ return camelcaseKeys(state.records, { deep: true });
+ },
+ },
+ actions: () => ({
+ async search({ search, page, sort }) {
+ this.setUIFlag({ fetchingList: true });
+ try {
+ const {
+ data: { payload, meta },
+ } = await CompanyAPI.search(search, page, sort);
+ this.records = payload;
+ this.setMeta(meta);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ this.setUIFlag({ fetchingList: false });
+ }
+ },
+ }),
+});
diff --git a/app/javascript/entrypoints/dashboard.js b/app/javascript/entrypoints/dashboard.js
index 88bd35926..cd2cda6bd 100644
--- a/app/javascript/entrypoints/dashboard.js
+++ b/app/javascript/entrypoints/dashboard.js
@@ -16,6 +16,7 @@ import createAxios from 'dashboard/helper/APIHelper';
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
import { sync } from 'vuex-router-sync';
+import { createPinia } from 'pinia';
import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
@@ -41,9 +42,12 @@ const i18n = createI18n({
sync(store, router);
+const pinia = createPinia();
+
const app = createApp(App);
app.use(i18n);
app.use(store);
+app.use(pinia);
app.use(router);
// [VITE] Disabled this, need to renable later
diff --git a/app/javascript/entrypoints/sdk.js b/app/javascript/entrypoints/sdk.js
index 2a919f42c..6e1639b1c 100755
--- a/app/javascript/entrypoints/sdk.js
+++ b/app/javascript/entrypoints/sdk.js
@@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
welcomeDescription: chatwootSettings.welcomeDescription || '',
availableMessage: chatwootSettings.availableMessage || '',
unavailableMessage: chatwootSettings.unavailableMessage || '',
- enableFileUpload: chatwootSettings.enableFileUpload ?? true,
+ enableFileUpload: chatwootSettings.enableFileUpload,
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index 7a5d3a131..e229a0442 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -45,6 +45,7 @@
"bot-outline": "M17.753 14a2.25 2.25 0 0 1 2.25 2.25v.905a3.75 3.75 0 0 1-1.307 2.846C17.13 21.345 14.89 22 12 22c-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.306-2.843v-.908A2.25 2.25 0 0 1 6.253 14h11.5Zm0 1.5h-11.5a.75.75 0 0 0-.75.75v.908c0 .655.286 1.278.784 1.706C7.545 19.945 9.44 20.502 12 20.502c2.56 0 4.458-.557 5.719-1.64a2.25 2.25 0 0 0 .784-1.706v-.906a.75.75 0 0 0-.75-.75ZM11.898 2.008 12 2a.75.75 0 0 1 .743.648l.007.102V3.5h3.5a2.25 2.25 0 0 1 2.25 2.25v4.505a2.25 2.25 0 0 1-2.25 2.25h-8.5a2.25 2.25 0 0 1-2.25-2.25V5.75A2.25 2.25 0 0 1 7.75 3.5h3.5v-.749a.75.75 0 0 1 .648-.743L12 2l-.102.007ZM16.25 5h-8.5a.75.75 0 0 0-.75.75v4.505c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75V5.75a.75.75 0 0 0-.75-.75Zm-6.5 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm4.492 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
"brand-facebook-outline": "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z",
"brand-instagram-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
+ "brand-tiktok-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
"brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z",
"brand-line-outline": "M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314",
"brand-linkedin-outline": "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
diff --git a/app/javascript/shared/components/ResizableTextArea.vue b/app/javascript/shared/components/ResizableTextArea.vue
index 7f80124c2..a68e34fdd 100644
--- a/app/javascript/shared/components/ResizableTextArea.vue
+++ b/app/javascript/shared/components/ResizableTextArea.vue
@@ -111,10 +111,15 @@ export default {
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
toggleSignatureInEditor(signatureEnabled) {
- const valueWithSignature = signatureEnabled
+ let valueWithSignature = signatureEnabled
? appendSignature(this.modelValue, this.cleanedSignature)
: removeSignature(this.modelValue, this.cleanedSignature);
+ // Clean up whitespace when removing signature from empty body
+ if (!signatureEnabled && !valueWithSignature.trim()) {
+ valueWithSignature = '';
+ }
+
this.$emit('update:modelValue', valueWithSignature);
this.$emit('input', valueWithSignature);
diff --git a/app/javascript/shared/composables/specs/useLocale.spec.js b/app/javascript/shared/composables/specs/useLocale.spec.js
new file mode 100644
index 000000000..2b9f04b9d
--- /dev/null
+++ b/app/javascript/shared/composables/specs/useLocale.spec.js
@@ -0,0 +1,188 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useLocale } from '../useLocale';
+import { useI18n } from 'vue-i18n';
+import { ref } from 'vue';
+
+vi.mock('vue-i18n');
+
+describe('useLocale', () => {
+ beforeEach(() => {
+ vi.mocked(useI18n).mockReturnValue({
+ locale: ref('en-US'),
+ });
+ });
+
+ describe('resolvedLocale', () => {
+ it('should return normalized locale for valid hyphen-based tags', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ expect(resolvedLocale.value).toBe('en-US');
+ });
+
+ it('should normalize underscore-based locale tags to hyphens', () => {
+ const mockLocale = ref('pt_BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should normalize pt_BR to pt-BR
+ expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
+ });
+
+ it('should normalize zh_CN to zh-CN or fall back to zh', () => {
+ const mockLocale = ref('zh_CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should normalize zh_CN to zh-CN or fall back to zh
+ expect(resolvedLocale.value).toMatch(/^zh(-CN)?$/);
+ });
+
+ it('should normalize en_US to en-US or fall back to en', () => {
+ const mockLocale = ref('en_US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should normalize en_US to en-US or fall back to en
+ expect(resolvedLocale.value).toMatch(/^en(-US)?$/);
+ });
+
+ it('should fall back to base language when specific locale not supported', () => {
+ // Use a specific locale that might not be fully supported
+ const mockLocale = ref('pt-BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should return either pt-BR or pt (base language)
+ expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
+ });
+
+ it('should fall back to English for completely unsupported locales', () => {
+ const mockLocale = ref('xx-YY');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should fall back to 'en'
+ expect(resolvedLocale.value).toBe('en');
+ });
+
+ it('should handle null locale gracefully', () => {
+ const mockLocale = ref(null);
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should fall back to 'en'
+ expect(resolvedLocale.value).toBe('en');
+ });
+
+ it('should handle undefined locale gracefully', () => {
+ const mockLocale = ref(undefined);
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should fall back to 'en'
+ expect(resolvedLocale.value).toBe('en');
+ });
+
+ it('should handle base language code without region', () => {
+ const mockLocale = ref('pt');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should work with base language
+ expect(resolvedLocale.value).toBe('pt');
+ });
+
+ it('should handle multiple underscores in locale tag', () => {
+ const mockLocale = ref('zh_Hans_CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ // Should normalize all underscores to hyphens
+ expect(resolvedLocale.value).toMatch(/^zh(-Hans-CN|-Hans|-CN)?$/);
+ });
+
+ it('should be reactive to locale changes', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+
+ expect(resolvedLocale.value).toBe('en-US');
+
+ // Change locale
+ mockLocale.value = 'pt_BR';
+
+ // Should update reactively
+ expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
+ });
+
+ it('should work with common locales', () => {
+ const testCases = [
+ { input: 'de-DE', expected: /^de(-DE)?$/ },
+ { input: 'fr-FR', expected: /^fr(-FR)?$/ },
+ { input: 'es-ES', expected: /^es(-ES)?$/ },
+ { input: 'ja-JP', expected: /^ja(-JP)?$/ },
+ { input: 'ko-KR', expected: /^ko(-KR)?$/ },
+ { input: 'ar-SA', expected: /^ar(-SA)?$/ },
+ { input: 'hi-IN', expected: /^hi(-IN)?$/ },
+ { input: 'ru-RU', expected: /^ru(-RU)?$/ },
+ { input: 'tr-TR', expected: /^tr(-TR)?$/ },
+ ];
+
+ testCases.forEach(({ input, expected }) => {
+ const mockLocale = ref(input);
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+ expect(resolvedLocale.value).toMatch(expected);
+ });
+ });
+ });
+
+ describe('locale (raw)', () => {
+ it('should expose raw locale value', () => {
+ const mockLocale = ref('pt_BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { locale } = useLocale();
+ // Raw locale should be unchanged
+ expect(locale.value).toBe('pt_BR');
+ });
+
+ it('should be reactive', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { locale } = useLocale();
+
+ expect(locale.value).toBe('en-US');
+
+ mockLocale.value = 'pt-BR';
+
+ expect(locale.value).toBe('pt-BR');
+ });
+ });
+
+ describe('Intl API compatibility', () => {
+ it('should work with Intl.NumberFormat', () => {
+ const mockLocale = ref('pt_BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+
+ // Should not throw error
+ expect(() => {
+ new Intl.NumberFormat(resolvedLocale.value).format(1234567);
+ }).not.toThrow();
+ });
+
+ it('should work with Intl.DateTimeFormat', () => {
+ const mockLocale = ref('zh_CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+
+ // Should not throw error
+ expect(() => {
+ new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
+ }).not.toThrow();
+ });
+
+ it('should work with Intl.Collator', () => {
+ const mockLocale = ref('en_US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { resolvedLocale } = useLocale();
+
+ // Should not throw error
+ expect(() => {
+ new Intl.Collator(resolvedLocale.value).compare('a', 'b');
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/app/javascript/shared/composables/specs/useNumberFormatter.spec.js b/app/javascript/shared/composables/specs/useNumberFormatter.spec.js
new file mode 100644
index 000000000..3a9b8d41b
--- /dev/null
+++ b/app/javascript/shared/composables/specs/useNumberFormatter.spec.js
@@ -0,0 +1,364 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useNumberFormatter } from '../useNumberFormatter';
+import { useI18n } from 'vue-i18n';
+import { ref } from 'vue';
+
+vi.mock('vue-i18n');
+
+describe('useNumberFormatter', () => {
+ beforeEach(() => {
+ vi.mocked(useI18n).mockReturnValue({
+ locale: ref('en-US'),
+ });
+ });
+
+ describe('formatCompactNumber', () => {
+ it('should return exact numbers for values under 1,000', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(0)).toBe('0');
+ expect(formatCompactNumber(1)).toBe('1');
+ expect(formatCompactNumber(42)).toBe('42');
+ expect(formatCompactNumber(999)).toBe('999');
+ });
+
+ it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1000)).toBe('1k');
+ expect(formatCompactNumber(1020)).toBe('1k+');
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ expect(formatCompactNumber(1999)).toBe('1k+');
+ expect(formatCompactNumber(2000)).toBe('2k');
+ expect(formatCompactNumber(15000)).toBe('15k');
+ expect(formatCompactNumber(15500)).toBe('15k+');
+ expect(formatCompactNumber(999999)).toBe('999k+');
+ });
+
+ it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1000000)).toBe('1M');
+ expect(formatCompactNumber(1000001)).toBe('1.0M');
+ expect(formatCompactNumber(1200000)).toBe('1.2M');
+ expect(formatCompactNumber(1234000)).toBe('1.2M');
+ expect(formatCompactNumber(2500000)).toBe('2.5M');
+ expect(formatCompactNumber(10000000)).toBe('10M');
+ expect(formatCompactNumber(1000000000)).toBe('1B');
+ expect(formatCompactNumber(1100000000)).toBe('1.1B');
+ expect(formatCompactNumber(10000000000)).toBe('10B');
+ expect(formatCompactNumber(11000000000)).toBe('11B');
+ expect(formatCompactNumber(1000000000000)).toBe('1T');
+ expect(formatCompactNumber(1100000000000)).toBe('1.1T');
+ expect(formatCompactNumber(10000000000000)).toBe('10T');
+ expect(formatCompactNumber(11000000000000)).toBe('11T');
+ });
+
+ it('should handle edge cases gracefully', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(null)).toBe('0');
+ expect(formatCompactNumber(undefined)).toBe('0');
+ expect(formatCompactNumber(NaN)).toBe('0');
+ expect(formatCompactNumber('string')).toBe('0');
+ });
+
+ it('should handle negative numbers', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(-500)).toBe('-500');
+ expect(formatCompactNumber(-1000)).toBe('-1k');
+ expect(formatCompactNumber(-1500)).toBe('-1k+');
+ expect(formatCompactNumber(-2000)).toBe('-2k');
+ expect(formatCompactNumber(-1200000)).toBe('-1.2M');
+ });
+
+ it('should format with en-US locale', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ });
+
+ it('should format with de-DE locale', () => {
+ const mockLocale = ref('de-DE');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(2000)).toBe('2k');
+ });
+
+ it('should format with fr-FR locale (compact notation)', () => {
+ const mockLocale = ref('fr-FR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(1000000);
+ expect(result).toMatch(/1\s*M/); // French uses space before M
+ });
+
+ it('should format with ja-JP locale', () => {
+ const mockLocale = ref('ja-JP');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(999)).toBe('999');
+ });
+
+ it('should format with ar-SA locale (Arabic numerals)', () => {
+ const mockLocale = ref('ar-SA');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(5000);
+ expect(result).toContain('k');
+ expect(typeof result).toBe('string');
+ });
+
+ it('should format with es-ES locale', () => {
+ const mockLocale = ref('es-ES');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(7500)).toBe('7k+');
+ });
+
+ it('should format with hi-IN locale', () => {
+ const mockLocale = ref('hi-IN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(100000)).toBe('100k');
+ });
+
+ it('should format with ru-RU locale', () => {
+ const mockLocale = ref('ru-RU');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(3000)).toBe('3k');
+ });
+
+ it('should format with ko-KR locale (uses 만 for 10,000)', () => {
+ const mockLocale = ref('ko-KR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(2500000);
+ expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
+ });
+
+ it('should format with pt-BR locale', () => {
+ const mockLocale = ref('pt-BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(8888)).toBe('8k+');
+ });
+
+ it('should handle underscore-based locale tags (pt_BR)', () => {
+ const mockLocale = ref('pt_BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should normalize pt_BR to pt-BR and work correctly
+ expect(formatCompactNumber(8888)).toBe('8k+');
+ expect(formatCompactNumber(1000000)).toBe('1\u00a0mi');
+ });
+
+ it('should handle underscore-based locale tags (zh_CN)', () => {
+ const mockLocale = ref('zh_CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should normalize zh_CN to zh-CN and work correctly
+ expect(formatCompactNumber(999)).toBe('999');
+ expect(formatCompactNumber(5000)).toBe('5k');
+ });
+
+ it('should handle underscore-based locale tags (en_US)', () => {
+ const mockLocale = ref('en_US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should normalize en_US to en-US and work correctly
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ expect(formatCompactNumber(1000000)).toBe('1M');
+ });
+
+ it('should handle null/undefined locale gracefully', () => {
+ const mockLocale = ref(null);
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should fall back to 'en' locale
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ });
+
+ it('should fall back to base language when specific locale not supported', () => {
+ // Simulate a case where pt-BR might not be fully supported but pt is
+ const mockLocale = ref('pt-BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should work with either pt-BR or pt fallback
+ const result = formatCompactNumber(1500);
+ expect(result).toMatch(/1k\+/);
+ });
+
+ it('should fall back to English for completely unsupported locales', () => {
+ // Use a completely made-up locale
+ const mockLocale = ref('xx-YY');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should fall back to 'en' and work
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ expect(formatCompactNumber(1000000)).toBe('1M');
+ });
+
+ it('should handle edge case with only base language code', () => {
+ const mockLocale = ref('pt');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ // Should work with base language
+ expect(formatCompactNumber(2000)).toBe('2k');
+ });
+ });
+
+ describe('formatFullNumber', () => {
+ it('should format numbers with locale-specific formatting', () => {
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1000)).toBe('1,000');
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
+ expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
+ });
+
+ it('should handle edge cases gracefully', () => {
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(null)).toBe('0');
+ expect(formatFullNumber(undefined)).toBe('0');
+ expect(formatFullNumber(NaN)).toBe('0');
+ expect(formatFullNumber('string')).toBe('0');
+ });
+
+ it('should format with en-US locale (comma separator)', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ });
+
+ it('should format with de-DE locale (period separator)', () => {
+ const mockLocale = ref('de-DE');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(9876543)).toBe('9.876.543');
+ });
+
+ it('should format with es-ES locale (period separator)', () => {
+ const mockLocale = ref('es-ES');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(5555555)).toBe('5.555.555');
+ });
+
+ it('should format with zh-CN locale', () => {
+ const mockLocale = ref('zh-CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1000000)).toBe('1,000,000');
+ });
+
+ it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
+ const mockLocale = ref('ar-EG');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(7654321);
+ // Arabic locale uses Eastern Arabic numerals (٠-٩)
+ // Just verify it's formatted (length should be reasonable)
+ expect(result.length).toBeGreaterThan(6);
+ });
+
+ it('should format with fr-FR locale (narrow no-break space)', () => {
+ const mockLocale = ref('fr-FR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(3333333);
+ expect(result).toContain('3');
+ expect(result).toContain('333');
+ });
+
+ it('should format with hi-IN locale (Indian numbering system)', () => {
+ const mockLocale = ref('hi-IN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(9999999)).toBe('99,99,999');
+ });
+
+ it('should format with th-TH locale', () => {
+ const mockLocale = ref('th-TH');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(4444444)).toBe('4,444,444');
+ });
+
+ it('should format with tr-TR locale', () => {
+ const mockLocale = ref('tr-TR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(6666666)).toBe('6.666.666');
+ });
+
+ it('should format with pt-PT locale (space separator)', () => {
+ const mockLocale = ref('pt-PT');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(2222222);
+ // Portuguese (Portugal) uses narrow no-break space as separator
+ expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
+ });
+
+ it('should handle underscore-based locale tags (pt_BR)', () => {
+ const mockLocale = ref('pt_BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should normalize pt_BR to pt-BR and work correctly
+ expect(formatFullNumber(1234567)).toBe('1.234.567');
+ });
+
+ it('should handle underscore-based locale tags (zh_CN)', () => {
+ const mockLocale = ref('zh_CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should normalize zh_CN to zh-CN and work correctly
+ expect(formatFullNumber(1000000)).toBe('1,000,000');
+ });
+
+ it('should handle underscore-based locale tags (en_US)', () => {
+ const mockLocale = ref('en_US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should normalize en_US to en-US and work correctly
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ });
+
+ it('should handle null/undefined locale gracefully', () => {
+ const mockLocale = ref(null);
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should fall back to 'en' locale
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ });
+
+ it('should fall back to base language when specific locale not supported', () => {
+ // Simulate a case where pt-BR might not be fully supported but pt is
+ const mockLocale = ref('pt-BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should work with either pt-BR or pt fallback
+ const result = formatFullNumber(1234567);
+ // Portuguese uses period as thousands separator
+ expect(result).toMatch(/1[.,\s]234[.,\s]567/);
+ });
+
+ it('should fall back to English for completely unsupported locales', () => {
+ // Use a completely made-up locale
+ const mockLocale = ref('xx-YY');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should fall back to 'en' and work
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ });
+
+ it('should handle edge case with only base language code', () => {
+ const mockLocale = ref('pt');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ // Should work with base language
+ const result = formatFullNumber(1000000);
+ expect(result).toMatch(/1[.,\s]000[.,\s]000/);
+ });
+ });
+});
diff --git a/app/javascript/shared/composables/useLocale.js b/app/javascript/shared/composables/useLocale.js
new file mode 100644
index 000000000..b496d15dd
--- /dev/null
+++ b/app/javascript/shared/composables/useLocale.js
@@ -0,0 +1,57 @@
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+/**
+ * Composable for locale resolution and validation
+ * Provides a normalized, validated locale that works with Intl APIs
+ */
+export function useLocale() {
+ const { locale } = useI18n();
+
+ /**
+ * Resolves and validates the current locale for use with Intl APIs
+ *
+ * Handles multiple fallback scenarios:
+ * 1. Normalizes underscore-based tags (pt_BR → pt-BR, zh_CN → zh-CN)
+ * 2. Falls back to base language if specific locale unsupported (pt-BR → pt)
+ * 3. Falls back to English if base language unsupported (xx-YY → en)
+ *
+ * @returns {string} Valid BCP 47 locale tag for Intl APIs
+ *
+ * @example
+ * const { resolvedLocale } = useLocale();
+ * new Intl.NumberFormat(resolvedLocale.value).format(1234);
+ * new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
+ */
+ const resolvedLocale = computed(() => {
+ // Handle null/undefined locale
+ if (!locale.value) return 'en';
+
+ // Normalize underscore to hyphen (pt_BR → pt-BR, zh_CN → zh-CN)
+ const normalized = locale.value.replace(/_/g, '-');
+
+ // Check if the specific locale is supported (e.g., pt-BR, zh-CN)
+ const supportedLocales = Intl.NumberFormat.supportedLocalesOf([normalized]);
+ if (supportedLocales.length > 0) {
+ return normalized;
+ }
+
+ // If specific locale not supported, try base language (pt-BR → pt, zh-CN → zh)
+ const baseLocale = normalized.split('-')[0];
+ const baseSupportedLocales = Intl.NumberFormat.supportedLocalesOf([
+ baseLocale,
+ ]);
+ if (baseSupportedLocales.length > 0) {
+ return baseLocale;
+ }
+
+ // If base language also not supported, fall back to English
+ return 'en';
+ });
+
+ return {
+ resolvedLocale,
+ // Also expose the raw locale for cases where you need it
+ locale,
+ };
+}
diff --git a/app/javascript/shared/composables/useNumberFormatter.js b/app/javascript/shared/composables/useNumberFormatter.js
new file mode 100644
index 000000000..54ded0e93
--- /dev/null
+++ b/app/javascript/shared/composables/useNumberFormatter.js
@@ -0,0 +1,67 @@
+import { useLocale } from './useLocale';
+
+/**
+ * Composable for number formatting with i18n locale support
+ * Provides methods to format numbers in compact and full display formats
+ */
+export function useNumberFormatter() {
+ const { resolvedLocale } = useLocale();
+
+ /**
+ * Formats numbers for display with clean, minimal formatting
+ * - Up to 1,000: show exact number (e.g., 999)
+ * - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 → "1k", 1500 → "1k+")
+ * - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 → "1.2M")
+ *
+ * Uses browser-native Intl.NumberFormat with proper i18n locale support
+ *
+ * @param {number} num - The number to format
+ * @returns {string} Formatted number string
+ */
+ const formatCompactNumber = num => {
+ if (typeof num !== 'number' || Number.isNaN(num)) {
+ return '0';
+ }
+
+ // For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
+ if (Math.abs(num) < 1000) {
+ return new Intl.NumberFormat(resolvedLocale.value).format(num);
+ }
+
+ // For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
+ if (Math.abs(num) >= 1000000) {
+ const millions = num / 1000000;
+ return new Intl.NumberFormat(resolvedLocale.value, {
+ notation: 'compact',
+ compactDisplay: 'short',
+ maximumFractionDigits: 1,
+ minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
+ }).format(num);
+ }
+
+ // For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
+ // For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
+ const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
+ const remainder = Math.abs(num) % 1000;
+ const suffix = remainder === 0 ? 'k' : 'k+';
+ return `${new Intl.NumberFormat(resolvedLocale.value).format(thousands)}${suffix}`;
+ };
+
+ /**
+ * Format a number for full display with locale-specific formatting
+ * @param {number} num - The number to format
+ * @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
+ */
+ const formatFullNumber = num => {
+ if (typeof num !== 'number' || Number.isNaN(num)) {
+ return '0';
+ }
+
+ return new Intl.NumberFormat(resolvedLocale.value).format(num);
+ };
+
+ return {
+ formatCompactNumber,
+ formatFullNumber,
+ };
+}
diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js
index 0d5661d26..ef8f24155 100644
--- a/app/javascript/shared/constants/busEvents.js
+++ b/app/javascript/shared/constants/busEvents.js
@@ -4,7 +4,6 @@ export const BUS_EVENTS = {
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
MESSAGE_SENT: 'MESSAGE_SENT',
- FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
WEBSOCKET_RECONNECT: 'WEBSOCKET_RECONNECT',
diff --git a/app/javascript/shared/constants/links.js b/app/javascript/shared/constants/links.js
index 94d3de3f6..4797cec4e 100644
--- a/app/javascript/shared/constants/links.js
+++ b/app/javascript/shared/constants/links.js
@@ -5,6 +5,7 @@ export const REPLY_POLICY = {
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
+ TIKTOK: 'https://business-api.tiktok.com/portal/docs?id=1832184236919810',
};
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
diff --git a/app/javascript/shared/helpers/MessageTypeHelper.js b/app/javascript/shared/helpers/MessageTypeHelper.js
index 1e6cf454c..d2bc6f187 100644
--- a/app/javascript/shared/helpers/MessageTypeHelper.js
+++ b/app/javascript/shared/helpers/MessageTypeHelper.js
@@ -8,6 +8,8 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 2000,
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
INSTAGRAM: 1000,
+ // https://business-api.tiktok.com/portal/docs?id=1832184403754242
+ TIKTOK: 6000,
// https://www.twilio.com/docs/glossary/what-sms-character-limit
TWILIO_SMS: 320,
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index 4c5558823..071c67d0e 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -14,6 +14,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
+ INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -21,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
+ INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -127,6 +129,8 @@ export default {
badgeKey = this.twilioBadge;
} else if (this.isAWhatsAppChannel) {
badgeKey = 'whatsapp';
+ } else if (this.isATiktokChannel) {
+ badgeKey = 'tiktok';
}
return badgeKey || this.channelType;
},
@@ -139,6 +143,9 @@ export default {
isAnInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
+ isATiktokChannel() {
+ return this.channelType === INBOX_TYPES.TIKTOK;
+ },
},
methods: {
inboxHasFeature(feature) {
diff --git a/app/javascript/survey/i18n/locale/he.json b/app/javascript/survey/i18n/locale/he.json
index 720d32936..41a61f67e 100644
--- a/app/javascript/survey/i18n/locale/he.json
+++ b/app/javascript/survey/i18n/locale/he.json
@@ -1,8 +1,8 @@
{
"SURVEY": {
- "DESCRIPTION": "לקוח יקר 👋, אנא הקדישו כמה רגעים כדי לשתף משוב על השיחה שניהלתם עם {inboxName}.",
+ "DESCRIPTION": "לקוח יקר 👋, אנא הקדש כמה רגעים כדי לשתף משוב על השיחה שניהלת עם {inboxName}.",
"RATING": {
- "LABEL": "דרג את השיחה",
+ "LABEL": "דרג את השיחה שלך",
"SUCCESS_MESSAGE": "תודה על שליחת הדירוג"
},
"FEEDBACK": {
diff --git a/app/javascript/widget/components/Availability/AvailabilityContainer.vue b/app/javascript/widget/components/Availability/AvailabilityContainer.vue
index 367564751..28abfa082 100644
--- a/app/javascript/widget/components/Availability/AvailabilityContainer.vue
+++ b/app/javascript/widget/components/Availability/AvailabilityContainer.vue
@@ -1,5 +1,5 @@