Merge branch 'develop' into feat/github-integration
This commit is contained in:
-177
@@ -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;
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
-9
@@ -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"
|
||||
|
||||
+19
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user