Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-08-27 11:44:46 +05:30
committed by GitHub
77 changed files with 2764 additions and 1782 deletions
@@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def sync_templates
unless @inbox.channel.is_a?(Channel::Whatsapp)
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
end
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
@@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
[]
end
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -1,177 +0,0 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
:title="key"
>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -41,9 +41,7 @@ const originalEmailText = computed(() => {
});
const originalEmailHtml = computed(
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
() => contentAttributes?.value?.email?.htmlContent?.full || ''
);
const messageContent = computed(() => {
@@ -1,4 +1,13 @@
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
* 1. Displays the template text with variable placeholders.
* 2. Generates input fields for each variable in the template.
* 3. Validates that all variables are filled before sending.
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
@@ -6,15 +6,11 @@ import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -196,17 +192,16 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
channelType = INBOX_TYPES.INSTAGRAM;
}
return ALLOWED_FILE_TYPES;
return getAllowedFileTypesByChannel({
channelType,
medium: this.inbox?.medium,
});
},
enableDragAndDrop() {
return !this.newConversationModalActive;
@@ -1,10 +1,10 @@
<script>
import TemplatesPicker from './TemplatesPicker.vue';
import TemplateParser from './TemplateParser.vue';
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
export default {
components: {
TemplatesPicker,
TemplateParser,
WhatsAppTemplateReply,
},
props: {
show: {
@@ -68,7 +68,7 @@ export default {
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
<WhatsAppTemplateReply
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@@ -1,13 +1,4 @@
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
* 1. Displays the template text with variable placeholders.
* 2. Generates input fields for each variable in the template.
* 3. Validates that all variables are filled before sending.
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
@@ -13,6 +13,7 @@ vi.mock('dashboard/composables', () => ({
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper');
vi.mock('@chatwoot/utils');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
@@ -22,6 +23,11 @@ describe('useFileUpload', () => {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
const inbox = {
channel_type: 'Channel::WhatsApp',
medium: 'whatsapp',
};
beforeEach(() => {
vi.clearAllMocks();
@@ -37,11 +43,12 @@ describe('useFileUpload', () => {
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
});
it('should handle direct file upload when enabled', () => {
it('handles direct file upload when direct uploads enabled', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -52,6 +59,16 @@ describe('useFileUpload', () => {
onFileUpload(mockFile);
// size rules called with inbox + mime
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
// size check called with max from helper
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
@@ -63,7 +80,7 @@ describe('useFileUpload', () => {
});
});
it('should handle indirect file upload when direct upload is disabled', () => {
it('handles indirect file upload when direct upload disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
@@ -75,22 +92,24 @@ describe('useFileUpload', () => {
});
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('should show alert when file size exceeds limit', () => {
it('shows alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -100,28 +119,37 @@ describe('useFileUpload', () => {
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should use different max file size for Twilio SMS channel', () => {
it('uses per-mime limits from helper', () => {
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
mime.startsWith('image/') ? 10 : 50
);
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: true,
inbox,
attachFile: mockAttachFile,
});
DirectUpload.mockImplementation(() => ({
create: cb => cb(null, { signed_id: 'blob' }),
}));
onFileUpload(mockFile);
expect(checkFileSizeLimit).toHaveBeenCalledWith(
mockFile,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
);
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
});
it('should handle direct upload errors', () => {
it('handles direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -131,15 +159,16 @@ describe('useFileUpload', () => {
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should do nothing when file is null', () => {
it('does nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
@@ -1,22 +1,17 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
/**
* Composable for handling file uploads in conversations
* @param {Object} options - Configuration options
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
* @param {Function} options.attachFile - Callback function to handle file attachment
* @returns {Object} File upload methods and utilities
* @param {Object} options
* @param {Object} options.inbox - Current inbox object (has channel_type, medium, etc.)
* @param {Function} options.attachFile - Callback to handle file attachment
*/
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
export const useFileUpload = ({ inbox, attachFile }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
@@ -24,57 +19,66 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const maxFileSize = computed(() =>
isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE
);
// helper: compute max upload size for a given file's mime
const maxSizeFor = mime =>
getMaxUploadSizeByChannel({
channelType: inbox?.channel_type,
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
mime, // e.g. 'image/png'
});
const alertOverLimit = maxSizeMB =>
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
})
);
const handleDirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
};
const handleIndirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
attachFile({ file });
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
attachFile({ file });
};
const onFileUpload = file => {
@@ -85,7 +89,5 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
}
};
return {
onFileUpload,
};
return { onFileUpload };
};
@@ -1,4 +1,4 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
import posthog from 'posthog-js';
/**
* AnalyticsHelper class to initialize and track user analytics
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
posthog.init(this.analyticsToken, {
api_host: 'https://app.posthog.com',
capture_pageview: false,
persistence: 'localStorage+cookie',
});
this.analytics = analytics;
this.analytics = posthog;
}
/**
@@ -43,8 +45,7 @@ export class AnalyticsHelper {
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
this.analytics.identify(this.user.id.toString(), {
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
@@ -55,7 +56,7 @@ export class AnalyticsHelper {
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
this.analytics.group('company', currentAccount.id.toString(), {
name: currentAccount.name,
});
}
@@ -71,12 +72,7 @@ export class AnalyticsHelper {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
this.analytics.capture(eventName, properties);
}
/**
@@ -89,9 +85,9 @@ export class AnalyticsHelper {
return;
}
this.analytics.page(params);
this.analytics.capture('$pageview', params);
}
}
// This object is shared across, the init is called in app/javascript/packs/application.js
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
export default new AnalyticsHelper(window.analyticsConfig);
@@ -1,15 +1,11 @@
import helperObject, { AnalyticsHelper } from '../';
vi.mock('@june-so/analytics-next', () => ({
AnalyticsBrowser: {
load: () => [
{
identify: vi.fn(),
track: vi.fn(),
page: vi.fn(),
group: vi.fn(),
},
],
vi.mock('posthog-js', () => ({
default: {
init: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
group: vi.fn(),
},
}));
@@ -26,12 +22,12 @@ describe('AnalyticsHelper', () => {
});
describe('init', () => {
it('should initialize the analytics browser with the correct token', async () => {
it('should initialize posthog with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize the analytics browser if token is not provided', async () => {
it('should not initialize posthog if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
@@ -43,36 +39,36 @@ describe('AnalyticsHelper', () => {
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
});
it('should call identify on analytics browser with correct arguments', () => {
it('should call identify on posthog with correct arguments', () => {
analyticsHelper.identify({
id: '123',
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '1',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 1,
});
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
'test@example.com',
{
userId: '123',
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
}
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
});
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company',
'1',
{ name: 'Account 1' }
);
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
});
it('should call identify on analytics browser without group', () => {
it('should call identify on posthog without group', () => {
analyticsHelper.identify({
id: '123',
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '5',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 5,
});
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
@@ -87,29 +83,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.user = { id: '123' };
analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.user = { id: 123 };
});
it('should call track on analytics browser with correct arguments', () => {
it('should call capture on posthog with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: { prop1: 'value1', prop2: 'value2' },
});
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{ prop1: 'value1', prop2: 'value2' }
);
});
it('should call track on analytics browser with default properties', () => {
it('should call capture on posthog with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: {},
});
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{}
);
});
it('should not call track on analytics browser if analytics is not initialized', () => {
it('should not call capture on posthog if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
@@ -118,19 +112,22 @@ describe('AnalyticsHelper', () => {
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { page: vi.fn() };
analyticsHelper.analytics = { capture: vi.fn() };
});
it('should call the analytics.page method with the correct arguments', () => {
it('should call the capture method for pageview with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
};
analyticsHelper.page(params);
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'$pageview',
params
);
});
it('should not call analytics.page if analytics is null', () => {
it('should not call analytics.capture if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null);
@@ -330,13 +330,14 @@
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"CONFIGURATION": {
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
@@ -1,10 +1,7 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import { DirectUpload } from 'activestorage';
export default {
@@ -13,7 +10,22 @@ export default {
accountId: 'getCurrentAccountId',
}),
},
methods: {
maxSizeFor(mime) {
return getMaxUploadSizeByChannel({
channelType: this.inbox?.channel_type,
medium: this.inbox?.medium, // e.g. 'sms' | 'whatsapp'
mime, // e.g. 'image/png'
});
},
alertOverLimit(maxSizeMB) {
useAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
})
);
},
onFileUpload(file) {
if (this.globalConfig.directUploadsEnabled) {
this.onDirectFileUpload(file);
@@ -21,59 +33,52 @@ export default {
this.onIndirectFileUpload(file);
}
},
onDirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) return;
if (!file) {
const mime = file.file?.type || file.type;
const maxSizeMB = this.maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
this.alertOverLimit(maxSizeMB);
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
this.currentUser.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
this.attachFile({ file, blob });
}
});
} else {
useAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
this.currentUser.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
this.attachFile({ file, blob });
}
});
},
onIndirectFileUpload(file) {
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE;
if (!file) {
if (!file) return;
const mime = file.file?.type || file.type;
const maxSizeMB = this.maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
this.alertOverLimit(maxSizeMB);
return;
}
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
this.attachFile({ file });
} else {
useAlert(
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
})
);
}
this.attachFile({ file });
},
},
};
@@ -116,16 +116,22 @@ export default {
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@@ -144,6 +150,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -676,7 +683,7 @@ export default {
}}
</p>
</label>
<div class="pb-4">
<div v-if="!isAVoiceChannel" class="pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
@@ -22,7 +22,6 @@ const state = reactive({
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
@@ -33,7 +32,6 @@ const validationRules = {
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
@@ -67,7 +62,6 @@ function getProviderConfig() {
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
@@ -160,17 +154,6 @@ async function createChannel() {
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
@@ -126,6 +126,25 @@ export default {
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
@@ -36,7 +36,6 @@ export const CONVERSATION_PRIORITY_ORDER = {
// Size in mega bytes
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
export const MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL = 5;
export const ALLOWED_FILE_TYPES =
'image/*,' +
@@ -50,18 +49,6 @@ export const ALLOWED_FILE_TYPES =
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
'application/vnd.openxmlformats-officedocument.wordprocessingml.document,';
export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
'image/png, image/jpeg,' +
'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
'video/mp4,' +
'application/pdf,';
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
export const CSAT_RATINGS = [
{
key: 'disappointed',
+3 -3
View File
@@ -57,15 +57,15 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
},
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
@@ -150,6 +150,53 @@ const findNextSlot = (currentDay, currentMinutes, openDays) =>
})
.find(Boolean) || null;
/**
* Convert slot details from inbox timezone to user timezone
* @private
* @param {Date} time - Current time
* @param {string} inboxTz - Inbox timezone
* @param {Object} slotDetails - Original slot details
* @returns {Object} Slot details with user timezone adjustments
*/
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
if (!slotDetails) return null;
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// If timezones match, no conversion needed
if (inboxTz === userTz) return slotDetails;
// Calculate when the slot opens (absolute time)
const now = time instanceof Date ? time : new Date(time);
const openingTime = new Date(
now.getTime() + slotDetails.minutesUntilOpen * 60000
);
// Convert to user timezone
const openingInUserTz = getDateInTimezone(openingTime, userTz);
const nowInUserTz = getDateInTimezone(now, userTz);
// Calculate days difference in user timezone
const openingDate = new Date(openingInUserTz);
openingDate.setHours(0, 0, 0, 0);
const todayDate = new Date(nowInUserTz);
todayDate.setHours(0, 0, 0, 0);
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
// Return with user timezone adjustments
return {
...slotDetails,
config: {
...slotDetails.config,
openHour: openingInUserTz.getHours(),
openMinutes: openingInUserTz.getMinutes(),
dayOfWeek: openingInUserTz.getDay(),
},
daysUntilOpen,
dayOfWeek: openingInUserTz.getDay(),
};
};
// ---------------------------------------------------------------------------
// Exported functions
// ---------------------------------------------------------------------------
@@ -213,6 +260,7 @@ export const isInWorkingHours = (time, utcOffset, workingHours = []) => {
/**
* Find next available slot with detailed information
* Returns times adjusted to user's timezone for display
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
@@ -238,10 +286,13 @@ export const findNextAvailableSlotDetails = (
currentMinutes,
openDays
);
if (todaySlot) return todaySlot;
// Find next slot
return findNextSlot(currentDay, currentMinutes, openDays);
// Find the slot (today or next)
const slotDetails =
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
// Convert to user timezone for display
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
};
/**
@@ -0,0 +1,7 @@
class Channels::Twilio::TemplatesSyncJob < ApplicationJob
queue_as :low
def perform(twilio_channel)
Twilio::TemplateSyncService.new(channel: twilio_channel).call
end
end
+1
View File
@@ -25,6 +25,7 @@
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
#
class Category < ApplicationRecord
paginates_per Limits::CATEGORIES_PER_PAGE
belongs_to :account
belongs_to :portal
has_many :folders, dependent: :destroy_async
+12 -10
View File
@@ -2,16 +2,18 @@
#
# Table name: channel_twilio_sms
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_sid :string
# auth_token :string not null
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# id :bigint not null, primary key
# account_sid :string not null
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
# content_templates_last_updated :datetime
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
+41 -1
View File
@@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService
def perform_reply
begin
twilio_message = channel.send_message(**message_params)
twilio_message = if template_params.present?
send_template_message
else
channel.send_message(**message_params)
end
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
message.update!(source_id: twilio_message.sid) if twilio_message
end
def send_template_message
content_sid, content_variables = process_template_params
if content_sid.blank?
message.update!(status: :failed, external_error: 'Template not found')
return nil
end
send_params = {
to: contact_inbox.source_id,
content_sid: content_sid
}
send_params[:content_variables] = content_variables.to_json if content_variables.present?
send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true)
# Add messaging service or from number
send_params = send_params.merge(channel.send(:send_message_from))
channel.send(:client).messages.create(**send_params)
end
def template_params
message.additional_attributes && message.additional_attributes['template_params']
end
def process_template_params
return [nil, nil] if template_params.blank?
Twilio::TemplateProcessorService.new(
channel: channel,
template_params: template_params,
message: message
).call
end
def message_params
{
body: message.outgoing_content,
@@ -0,0 +1,121 @@
class Twilio::TemplateProcessorService
pattr_initialize [:channel!, :template_params, :message]
def call
return [nil, nil] if template_params.blank?
template = find_template
return [nil, nil] if template.blank?
content_variables = build_content_variables(template)
[template['content_sid'], content_variables]
end
private
def find_template
channel.content_templates&.dig('templates')&.find do |template|
template['friendly_name'] == template_params['name'] &&
template['language'] == (template_params['language'] || 'en') &&
template['status'] == 'approved'
end
end
def build_content_variables(template)
case template['template_type']
when 'text', 'quick_reply'
convert_text_template(template_params) # Text and quick reply templates use body variables
when 'media'
convert_media_template(template_params)
else
{}
end
end
def convert_text_template(chatwoot_params)
return process_key_value_params(chatwoot_params['processed_params']) if chatwoot_params['processed_params'].present?
process_whatsapp_format_params(chatwoot_params['parameters'])
end
def process_key_value_params(processed_params)
content_variables = {}
processed_params.each do |key, value|
content_variables[key.to_s] = value.to_s
end
content_variables
end
def process_whatsapp_format_params(parameters)
content_variables = {}
parameter_index = 1
parameters&.each do |component|
next unless component['type'] == 'body'
component['parameters']&.each do |param|
content_variables[parameter_index.to_s] = param['text']
parameter_index += 1
end
end
content_variables
end
def convert_media_template(chatwoot_params)
content_variables = {}
# Handle processed_params format (key-value pairs)
if chatwoot_params['processed_params'].present?
chatwoot_params['processed_params'].each do |key, value|
content_variables[key.to_s] = value.to_s
end
else
# Handle parameters format (WhatsApp Cloud API format)
parameter_index = 1
chatwoot_params['parameters']&.each do |component|
parameter_index = process_component(component, content_variables, parameter_index)
end
end
content_variables
end
def process_component(component, content_variables, parameter_index)
case component['type']
when 'header'
process_media_header(component, content_variables, parameter_index)
when 'body'
process_body_parameters(component, content_variables, parameter_index)
else
parameter_index
end
end
def process_media_header(component, content_variables, parameter_index)
media_param = component['parameters']&.first
return parameter_index unless media_param
media_link = extract_media_link(media_param)
if media_link
content_variables[parameter_index.to_s] = media_link
parameter_index + 1
else
parameter_index
end
end
def extract_media_link(media_param)
media_param.dig('image', 'link') ||
media_param.dig('video', 'link') ||
media_param.dig('document', 'link')
end
def process_body_parameters(component, content_variables, parameter_index)
component['parameters']&.each do |param|
content_variables[parameter_index.to_s] = param['text']
parameter_index += 1
end
parameter_index
end
end
@@ -0,0 +1,113 @@
class Twilio::TemplateSyncService
pattr_initialize [:channel!]
def call
fetch_templates_from_twilio
update_channel_templates
mark_templates_updated
rescue Twilio::REST::TwilioError => e
Rails.logger.error("Twilio template sync failed: #{e.message}")
false
end
private
def fetch_templates_from_twilio
@templates = client.content.v1.contents.list(limit: 1000)
end
def update_channel_templates
formatted_templates = @templates.map do |template|
{
content_sid: template.sid,
friendly_name: template.friendly_name,
language: template.language,
status: derive_status(template),
template_type: derive_template_type(template),
media_type: derive_media_type(template),
variables: template.variables || {},
category: derive_category(template),
body: extract_body_content(template),
created_at: template.date_created,
updated_at: template.date_updated
}
end
channel.update!(
content_templates: { templates: formatted_templates },
content_templates_last_updated: Time.current
)
end
def mark_templates_updated
channel.update!(content_templates_last_updated: Time.current)
end
def client
@client ||= channel.send(:client)
end
def derive_status(_template)
# For now, assume all fetched templates are approved
# In the future, this could check approval status from Twilio
'approved'
end
def derive_template_type(template)
template_types = template.types.keys
if template_types.include?('twilio/media')
'media'
elsif template_types.include?('twilio/quick-reply')
'quick_reply'
elsif template_types.include?('twilio/catalog')
'catalog'
else
'text'
end
end
def derive_media_type(template)
return nil unless derive_template_type(template) == 'media'
media_content = template.types['twilio/media']
return nil unless media_content
if media_content['image']
'image'
elsif media_content['video']
'video'
elsif media_content['document']
'document'
end
end
def derive_category(template)
# Map template friendly names or other attributes to categories
# For now, use utility as default
case template.friendly_name
when /marketing|promo|offer|sale/i
'marketing'
when /auth|otp|verify|code/i
'authentication'
else
'utility'
end
end
def extract_body_content(template)
template_types = template.types
if template_types['twilio/text']
template_types['twilio/text']['body']
elsif template_types['twilio/media']
template_types['twilio/media']['body']
elsif template_types['twilio/quick-reply']
template_types['twilio/quick-reply']['body']
elsif template_types['twilio/catalog']
template_types['twilio/catalog']['body']
else
''
end
end
end
+12 -3
View File
@@ -63,9 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
json.phone_number resource.channel.try(:phone_number)
json.medium resource.channel.try(:medium) if resource.twilio?
if resource.twilio? && Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
if resource.twilio?
json.content_templates resource.channel.try(:content_templates)
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
end
end
if resource.email?
@@ -118,3 +121,9 @@ if resource.whatsapp?
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="msapplication-TileColor" content="#1f93ff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#1f93ff">
<meta name="description" content="Chatwoot is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers.Chatwoot also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
<meta name="description" content="<%= @global_config['INSTALLATION_NAME'] %> is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers. <%= @global_config['INSTALLATION_NAME'] %> also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
<% if ENV['IOS_APP_IDENTIFIER'].present? %>
<meta name="apple-itunes-app" content='app-id=<%= ENV['IOS_APP_IDENTIFIER'] %>'>
<% end %>
@@ -36,7 +36,7 @@ for a collection of resources.
</svg>
</div>
<div class="flex-1 h-full min-w-0 relative">
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<select id="filter-select" class="appearance-none bg-transparent border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<option value="">All records</option>
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>