Merge branch 'develop' into feat/add_last_snoozed_option

This commit is contained in:
Vishnu Narayanan
2025-09-02 20:36:53 +05:30
committed by GitHub
97 changed files with 2742 additions and 776 deletions
+14 -22
View File
@@ -1,6 +1,7 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
defaults: &defaults
working_directory: ~/build
@@ -89,14 +90,6 @@ jobs:
command: |
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
# Swagger verification
- run:
@@ -108,10 +101,11 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
command: |
@@ -149,17 +143,11 @@ jobs:
command: pnpm run eslint
- run:
name: Run frontend tests
name: Run frontend tests (with coverage)
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run:
name: Run backend tests
@@ -167,18 +155,18 @@ jobs:
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/coverage.json
coverage/lcov.info
- run:
name: List coverage directory contents
@@ -189,3 +177,7 @@ jobs:
root: ~/build
paths:
- coverage
- store_artifacts:
path: coverage
destination: coverage
+2 -1
View File
@@ -227,6 +227,7 @@ group :test do
gem 'webmock'
# test profiling
gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -251,7 +252,7 @@ group :development, :test do
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false
gem 'simplecov', '>= 0.21', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
+9 -7
View File
@@ -219,7 +219,7 @@ GEM
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
docile (1.4.1)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
@@ -447,7 +447,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.12.0)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -857,11 +857,12 @@ GEM
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simplecov (0.17.1)
simplecov (0.22.0)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
faraday-mashify
@@ -1085,7 +1086,8 @@ DEPENDENCIES
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (= 0.17.1)
simplecov (>= 0.21)
simplecov_json_formatter
slack-ruby-client (~> 2.5.2)
spring
spring-watcher-listen
+11
View File
@@ -74,6 +74,17 @@ module PortalHelper
end
end
def generate_portal_brand_url(brand_url, referer)
url = URI.parse(brand_url.to_s)
query_params = Rack::Utils.parse_query(url.query)
query_params['utm_medium'] = 'helpcenter'
query_params['utm_campaign'] = 'branding'
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
url.query = query_params.to_query
url.to_s
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
@@ -62,6 +62,7 @@ const segmentsQuery = ref({});
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const labels = useMapGetter('labels/getLabels');
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
@@ -215,6 +216,7 @@ const setParamsForEditSegmentModal = () => {
countries,
filterTypes: contactFilterItems,
allCustomAttributes: useSnakeCase(contactAttributes.value),
labels: labels.value || [],
};
};
@@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
const props = defineProps({
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -32,6 +34,7 @@ const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'sendTwilioMessage',
'insertEmoji',
'addSignature',
'removeSignature',
@@ -63,6 +66,20 @@ const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const showTwilioContentTemplates = computed(() => {
return props.isTwilioWhatsAppInbox && props.inboxId;
});
const shouldShowEmojiButton = computed(() => {
return (
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
);
});
const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const setSignature = () => {
if (signatureToApply.value) {
if (sendWithSignature.value) {
@@ -125,7 +142,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -136,7 +153,7 @@ const keyboardEvents = {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
isRegularMessageMode.value &&
!props.isDropdownActive
) {
emit('sendMessage');
@@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents);
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
v-if="showTwilioContentTemplates"
:inbox-id="inboxId"
@send-message="emit('sendTwilioMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-if="shouldShowEmojiButton"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
@@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="hasSelectedInbox && !isWhatsappInbox"
v-if="hasSelectedInbox && isRegularMessageMode"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
v-if="isRegularMessageMode"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
@@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
isTwilioWhatsapp:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'whatsapp',
}));
const whatsappMessageTemplates = computed(() =>
@@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
isFromWhatsApp: true,
});
};
const handleSendTwilioMessage = async ({ message, templateParams }) => {
const twilioMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: twilioMessagePayload,
isFromWhatsApp: true,
});
};
const shouldShowMessageEditor = computed(() => {
return (
!inboxTypes.value.isWhatsapp &&
!showNoInboxAlert.value &&
!inboxTypes.value.isTwilioWhatsapp
);
});
</script>
<template>
@@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
@@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
@send-twilio-message="handleSendTwilioMessage"
/>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</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"
>
<div class="w-full">
<ContentTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</ContentTemplateParser>
</div>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
return inboxData?.content_templates?.templates || [];
});
const filteredTemplates = computed(() => {
return contentTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(searchQuery.value.toLowerCase()) &&
template.status === 'approved'
);
});
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return `${name} (${phoneNumber})`;
return phoneNumber ? `${name} (${phoneNumber})` : name;
}
return name;
};
@@ -53,6 +53,7 @@ const transformInbox = ({
email,
phoneNumber,
channelType,
medium,
...rest,
});
@@ -4,6 +4,10 @@ import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -63,6 +67,11 @@ const menuItems = computed(() => {
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
@@ -71,14 +80,14 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
class="flex relative items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
@@ -90,26 +99,26 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex gap-4 justify-between items-center w-full">
<span
class="text-sm shrink-0 truncate text-n-slate-11 flex items-center gap-1"
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<span
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
@@ -12,7 +13,6 @@ const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
@@ -23,7 +23,7 @@ const handleSubmit = async newDocument => {
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@@ -48,11 +48,7 @@ defineExpose({ dialogRef });
:show-confirm-button="false"
@close="handleClose"
>
<DocumentForm
ref="documentForm"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
<template #footer />
</Dialog>
</template>
@@ -1,9 +1,10 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -11,6 +12,8 @@ import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const { t } = useI18n();
const formState = {
@@ -20,14 +23,25 @@ const formState = {
const initialState = {
name: '',
url: '',
assistantId: null,
documentType: 'url',
pdfFile: null,
};
const state = reactive({ ...initialState });
const fileInputRef = ref(null);
const validationRules = {
url: { required, url, minLength: minLength(1) },
url: {
required: requiredIf(() => state.documentType === 'url'),
url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
},
assistantId: { required },
pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'),
},
};
const assistantList = computed(() =>
@@ -37,10 +51,17 @@ const assistantList = computed(() =>
}))
);
const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
];
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const hasPdfFileError = computed(() => v$.value.pdfFile.$error);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
@@ -50,14 +71,57 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleFileChange = event => {
const file = event.target.files[0];
if (file) {
if (file.type !== 'application/pdf') {
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.INVALID_TYPE'));
event.target.value = '';
return;
}
if (file.size > MAX_FILE_SIZE) {
// 10MB
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.TOO_LARGE'));
event.target.value = '';
return;
}
state.pdfFile = file;
state.name = file.name.replace(/\.pdf$/i, '');
}
};
const openFileDialog = () => {
// Use nextTick to ensure the ref is available
nextTick(() => {
if (fileInputRef.value) {
fileInputRef.value.click();
}
});
};
const prepareDocumentDetails = () => {
const formData = new FormData();
formData.append('document[assistant_id]', state.assistantId);
if (state.documentType === 'url') {
formData.append('document[external_link]', state.url);
formData.append('document[name]', state.name || state.url);
} else {
formData.append('document[pdf_file]', state.pdfFile);
formData.append(
'document[name]',
state.name || state.pdfFile.name.replace('.pdf', '')
);
// No need to send external_link for PDF - it's auto-generated in the backend
}
return formData;
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
@@ -71,13 +135,89 @@ const handleSubmit = async () => {
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label
for="documentType"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.LABEL') }}
</label>
<ComboBox
id="documentType"
v-model="state.documentType"
:options="documentTypeOptions"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-if="state.documentType === 'url'"
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<div v-if="state.documentType === 'pdf'" class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.LABEL') }}
</label>
<div class="relative">
<input
ref="fileInputRef"
type="file"
accept=".pdf"
class="hidden"
@change="handleFileChange"
/>
<Button
type="button"
:color="hasPdfFileError ? 'ruby' : 'slate'"
:variant="hasPdfFileError ? 'outline' : 'solid'"
class="!w-full !h-auto !justify-between !py-4"
@click="openFileDialog"
>
<template #default>
<div class="flex gap-2 items-center">
<div
class="flex justify-center items-center w-10 h-10 rounded-lg bg-n-slate-3"
>
<i class="text-xl i-ph-file-pdf text-n-slate-11" />
</div>
<div class="flex flex-col flex-1 gap-1 items-start">
<p class="m-0 text-sm font-medium text-n-slate-12">
{{
state.pdfFile
? state.pdfFile.name
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.CHOOSE_FILE')
}}
</p>
<p class="m-0 text-xs text-n-slate-11">
{{
state.pdfFile
? `${(state.pdfFile.size / 1024 / 1024).toFixed(2)} MB`
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.HELP_TEXT')
}}
</p>
</div>
</div>
<i class="i-lucide-upload text-n-slate-11" />
</template>
</Button>
</div>
<p v-if="formErrors.pdfFile" class="text-xs text-n-ruby-9">
{{ formErrors.pdfFile }}
</p>
</div>
<Input
v-model="state.name"
:label="t('CAPTAIN.DOCUMENTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
@@ -88,12 +228,12 @@ const handleSubmit = async () => {
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
variant="faded"
@@ -96,8 +96,12 @@ watch(
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:outline-n-weak focus:outline-n-brand"
:class="{ focused: open }"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{
focused: open,
'[&:not(.focused)]:dark:outline-n-weak [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6':
!hasError,
}"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
/>
@@ -0,0 +1,278 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.friendly_name) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
});
const categoryLabel = computed(() => {
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
});
const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
return matches.map(match => match.replace(/[{}]/g, ''));
});
const renderedTemplate = computed(() => {
let rendered = templateBody.value;
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
// Replace variables in the format {{1}}, {{2}}, etc.
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
const cleanVariable = variable.trim();
return processedParams.value[cleanVariable] || match;
});
}
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(
() => hasVariables.value || hasMediaVariable.value
),
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = {};
if (hasVariables.value) {
variablePattern.value.forEach(variable => {
processedParams.value[variable] = '';
});
}
if (hasMediaVariable.value && mediaVariableKey.value) {
processedParams.value[mediaVariableKey.value] = '';
}
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid || isFormInvalid.value) return;
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
const payload = {
message: renderedTemplate.value,
templateParams: {
name: friendly_name,
language,
processed_params: processedParameters,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaTemplate,
renderedTemplate,
v$,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.friendly_name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaVariable">
<!-- Media URL for media templates -->
<div v-if="hasMediaVariable" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
</p>
<div class="flex items-center mb-2.5">
<Input
v-model="processedParams[mediaVariableKey]"
type="url"
class="flex-1"
:placeholder="
templateMediaUrl ||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="hasVariables">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="variable in variablePattern"
:key="`variable-${variable}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams[variable]"
type="text"
class="flex-1"
:placeholder="
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: variable,
})
"
/>
</div>
</div>
<p
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid && !isFormInvalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -34,14 +34,17 @@ const formatOperatorLabel = operator => {
};
const formatFilterValue = value => {
// Case 1: null, undefined, empty string
if (!value) return '';
// Case 2: array → map each item, use name if present, else the item itself
if (Array.isArray(value)) {
return value.join(', ');
return value.map(item => item?.name ?? item).join(', ');
}
if (typeof value === 'object' && value.name) {
return value.name;
}
return value;
// Case 3: object with a "name" property → return name
// Case 4: primitive (string, number, etc.) → return as is
return value?.name ?? value;
};
</script>
@@ -66,6 +69,7 @@ const formatFilterValue = value => {
</span>
<span
v-if="filter.values"
:title="formatFilterValue(filter.values)"
class="lowercase truncate text-n-slate-12"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
@@ -50,6 +50,7 @@ export function useContactFilterContext() {
const { t } = useI18n();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const labels = useMapGetter('labels/getLabels');
const {
equalityOperators,
@@ -184,6 +185,20 @@ export function useContactFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.LABELS,
value: CONTACT_ATTRIBUTES.LABELS,
attributeName: t('CONTACTS_FILTER.ATTRIBUTES.LABELS'),
label: t('CONTACTS_FILTER.ATTRIBUTES.LABELS'),
inputType: 'multiSelect',
options: labels.value?.map(label => ({
id: label.title,
name: label.title,
})),
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
...customFilterTypes.value,
]);
@@ -28,6 +28,7 @@ export const CONTACT_ATTRIBUTES = {
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
BLOCKED: 'blocked',
LABELS: 'labels',
};
/**
@@ -2,23 +2,23 @@ import { computed } from 'vue';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
'Channel::Api': 'i-ri-cloudy-fill',
'Channel::Email': 'i-ri-mail-fill',
'Channel::FacebookPage': 'i-ri-messenger-fill',
'Channel::Line': 'i-ri-line-fill',
'Channel::Sms': 'i-ri-chat-1-fill',
'Channel::Telegram': 'i-ri-telegram-fill',
'Channel::TwilioSms': 'i-ri-chat-1-fill',
'Channel::Api': 'i-woot-api',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
microsoft: 'i-ri-microsoft-fill',
google: 'i-ri-google-fill',
microsoft: 'i-woot-outlook',
google: 'i-woot-gmail',
};
const channelIcon = computed(() => {
@@ -34,7 +34,7 @@ export function useChannelIcon(inbox) {
// Special case for Twilio whatsapp
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
icon = 'i-ri-whatsapp-fill';
icon = 'i-woot-whatsapp';
}
return icon ?? 'i-ri-global-fill';
@@ -4,19 +4,19 @@ describe('useChannelIcon', () => {
it('returns correct icon for API channel', () => {
const inbox = { channel_type: 'Channel::Api' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-cloudy-fill');
expect(icon).toBe('i-woot-api');
});
it('returns correct icon for Facebook channel', () => {
const inbox = { channel_type: 'Channel::FacebookPage' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-messenger-fill');
expect(icon).toBe('i-woot-messenger');
});
it('returns correct icon for WhatsApp channel', () => {
const inbox = { channel_type: 'Channel::Whatsapp' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-whatsapp-fill');
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
@@ -28,19 +28,19 @@ describe('useChannelIcon', () => {
it('returns correct icon for Line channel', () => {
const inbox = { channel_type: 'Channel::Line' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-line-fill');
expect(icon).toBe('i-woot-line');
});
it('returns correct icon for SMS channel', () => {
const inbox = { channel_type: 'Channel::Sms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
expect(icon).toBe('i-woot-sms');
});
it('returns correct icon for Telegram channel', () => {
const inbox = { channel_type: 'Channel::Telegram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-telegram-fill');
expect(icon).toBe('i-woot-telegram');
});
it('returns correct icon for Twitter channel', () => {
@@ -52,20 +52,20 @@ describe('useChannelIcon', () => {
it('returns correct icon for WebWidget channel', () => {
const inbox = { channel_type: 'Channel::WebWidget' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-global-fill');
expect(icon).toBe('i-woot-website');
});
it('returns correct icon for Instagram channel', () => {
const inbox = { channel_type: 'Channel::Instagram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-instagram-fill');
expect(icon).toBe('i-woot-instagram');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
expect(icon).toBe('i-woot-sms');
});
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
@@ -74,7 +74,7 @@ describe('useChannelIcon', () => {
medium: 'whatsapp',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-whatsapp-fill');
expect(icon).toBe('i-woot-whatsapp');
});
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
@@ -83,7 +83,7 @@ describe('useChannelIcon', () => {
medium: 'sms',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
expect(icon).toBe('i-woot-sms');
});
it('returns chat icon for Twilio SMS with undefined medium', () => {
@@ -92,7 +92,7 @@ describe('useChannelIcon', () => {
medium: undefined,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
expect(icon).toBe('i-woot-sms');
});
});
@@ -100,7 +100,7 @@ describe('useChannelIcon', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-mail-fill');
expect(icon).toBe('i-woot-mail');
});
it('returns Microsoft icon for Microsoft email provider', () => {
@@ -109,7 +109,7 @@ describe('useChannelIcon', () => {
provider: 'microsoft',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-microsoft-fill');
expect(icon).toBe('i-woot-outlook');
});
it('returns Google icon for Google email provider', () => {
@@ -118,7 +118,7 @@ describe('useChannelIcon', () => {
provider: 'google',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-google-fill');
expect(icon).toBe('i-woot-gmail');
});
});
@@ -25,7 +25,7 @@ const reauthorizationRequired = computed(() => {
<template>
<span
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<ChannelIcon :inbox="inbox" class="size-3" />
@@ -128,7 +128,7 @@ const menuItems = computed(() => {
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
badge: 'notifications/getHasUnreadNotifications',
count: 'notifications/getUnreadCount',
},
},
{
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import Icon from 'next/icon/Icon.vue';
@@ -16,12 +17,16 @@ const props = defineProps({
const emit = defineEmits(['toggle']);
const showBadge = useMapGetter(props.getterKeys.badge);
const dynamicCount = useMapGetter(props.getterKeys.count);
const count = computed(() =>
dynamicCount.value > 99 ? '99+' : dynamicCount.value
);
</script>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
role="button"
draggable="false"
:to="to"
@@ -40,9 +45,21 @@ const showBadge = useMapGetter(props.getterKeys.badge);
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
{{ label }}
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-blue-text outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
{{ count }}
</span>
</div>
<span
v-if="expandable"
v-show="isExpanded"
@@ -1,50 +1,57 @@
<script>
export default {
props: {
title: {
type: String,
required: true,
},
src: {
type: String,
required: true,
},
isComingSoon: {
type: Boolean,
default: false,
},
<script setup>
import Icon from 'next/icon/Icon.vue';
defineProps({
title: {
type: String,
required: true,
},
};
description: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
isComingSoon: {
type: Boolean,
default: false,
},
});
</script>
<template>
<button
class="relative bg-n-background cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-n-weak hover:border-n-brand hover:shadow-md hover:z-50 disabled:opacity-60"
class="relative bg-n-solid-1 gap-6 cursor-pointer rounded-2xl flex flex-col justify-start transition-all duration-200 ease-in -m-px py-6 px-5 items-start border border-solid border-n-weak"
:class="{
'hover:enabled:border-n-blue-9 hover:enabled:shadow-md disabled:opacity-60 disabled:cursor-not-allowed':
!isComingSoon,
'cursor-not-allowed disabled:opacity-80': isComingSoon,
}"
>
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<h3 class="text-n-slate-12 text-base text-center capitalize">
{{ title }}
</h3>
<div
class="flex size-10 items-center justify-center rounded-full bg-n-alpha-2"
>
<Icon :icon="icon" class="text-n-slate-10 size-6" />
</div>
<div class="flex flex-col items-start gap-1.5">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<p class="text-n-slate-11 text-start text-sm">
{{ description }}
</p>
</div>
<div
v-if="isComingSoon"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-md bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-base">
<span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span>
</div>
</button>
</template>
<style scoped lang="scss">
.inactive {
img {
filter: grayscale(100%);
}
&:hover {
@apply border-n-strong shadow-none cursor-not-allowed;
}
}
</style>
@@ -1,97 +1,75 @@
<script>
export default {
props: {
items: {
type: Array,
default: () => [],
},
},
computed: {
classObject() {
return 'w-full';
},
activeIndex() {
return this.items.findIndex(i => i.route === this.$route.name);
},
},
methods: {
isActive(item) {
return this.items.indexOf(item) === this.activeIndex;
},
isOver(item) {
return this.items.indexOf(item) < this.activeIndex;
},
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
});
const route = useRoute();
const activeIndex = computed(() => {
return props.items.findIndex(i => i.route === route.name);
});
const isActive = item => {
return props.items.indexOf(item) === activeIndex.value;
};
const isOver = item => {
return props.items.indexOf(item) < activeIndex.value;
};
</script>
<template>
<transition-group
name="wizard-items"
tag="div"
class="wizard-box"
:class="classObject"
>
<transition-group tag="div">
<div
v-for="item in items"
v-for="(item, index) in items"
:key="item.route"
class="item"
:class="{ active: isActive(item), over: isOver(item) }"
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden"
:class="
isOver(item)
? 'after:bg-n-blue-9 before:bg-n-blue-9'
: 'after:bg-n-weak before:bg-n-weak'
"
>
<div class="flex items-center">
<h3
class="text-n-slate-12 text-base font-medium pl-6 overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
<div
class="rounded-2xl flex-shrink-0 size-8 flex items-center justify-center left-2 outline outline-2 leading-4 z-10 top-5 bg-n-background"
:class="
isActive(item) || isOver(item) ? 'outline-n-blue-9' : 'outline-n-weak'
"
>
<span
class="text-xs font-bold"
:class="
isActive(item) || isOver(item)
? 'text-n-blue-11'
: 'text-n-slate-11'
"
>
{{ item.title }}
</h3>
<span v-if="isOver(item)" class="mx-1 mt-0.5 text-n-teal-9">
<fluent-icon icon="checkmark" />
{{ index + 1 }}
</span>
</div>
<span class="step">
{{ items.indexOf(item) + 1 }}
</span>
<p class="pl-6 m-0 mt-1.5 text-sm text-n-slate-11">
{{ item.body }}
</p>
<div class="flex flex-col items-start gap-1.5 pb-10 pt-1">
<div class="flex items-center">
<h3
class="text-sm font-medium overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
:class="
isActive(item) || isOver(item)
? 'text-n-blue-11'
: 'text-n-slate-12'
"
>
{{ item.title }}
</h3>
</div>
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
{{ item.body }}
</p>
</div>
</div>
</transition-group>
</template>
<style lang="scss" scoped>
.wizard-box {
.item {
@apply cursor-pointer after:bg-n-slate-6 before:bg-n-slate-6 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
&.active {
h3 {
@apply text-n-blue-text dark:text-n-blue-text;
}
.step {
@apply bg-n-brand dark:bg-n-brand;
}
}
&.over {
&::after {
@apply bg-n-brand dark:bg-n-brand;
}
.step {
@apply bg-n-brand dark:bg-n-brand;
}
& + .item {
&::before {
@apply bg-n-brand dark:bg-n-brand;
}
}
}
.step {
@apply bg-n-slate-7 rounded-2xl font-medium w-4 left-4 leading-4 z-10 absolute text-center text-white dark:text-white text-xxs top-5;
}
}
}
</style>
@@ -1,91 +1,87 @@
<script>
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
export default {
components: { ChannelSelector },
props: {
channel: {
type: Object,
required: true,
},
enabledFeatures: {
type: Object,
required: true,
},
},
emits: ['channelItemClick'],
computed: {
hasFbConfigured() {
return window.chatwootConfig?.fbAppId;
},
hasInstagramConfigured() {
return window.chatwootConfig?.instagramAppId;
},
isActive() {
const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) {
return false;
}
if (key === 'website') {
return this.enabledFeatures.channel_website;
}
if (key === 'facebook') {
return this.enabledFeatures.channel_facebook && this.hasFbConfigured;
}
if (key === 'email') {
return this.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return (
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
'api',
'whatsapp',
'sms',
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
isComingSoon() {
const { key } = this.channel;
// Show "Coming Soon" only if the channel is marked as coming soon
// and the corresponding feature flag is not enabled yet.
return ['voice'].includes(key) && !this.isActive;
},
const props = defineProps({
channel: {
type: Object,
required: true,
},
methods: {
getChannelThumbnail() {
if (this.channel.key === 'api' && this.channel.thumbnail) {
return this.channel.thumbnail;
}
return `/assets/images/dashboard/channels/${this.channel.key}.png`;
},
onItemClick() {
if (this.isActive) {
this.$emit('channelItemClick', this.channel.key);
}
},
enabledFeatures: {
type: Object,
required: true,
},
});
const emit = defineEmits(['channelItemClick']);
const hasFbConfigured = computed(() => {
return window.chatwootConfig?.fbAppId;
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
return false;
}
if (key === 'website') {
return props.enabledFeatures.channel_website;
}
if (key === 'facebook') {
return props.enabledFeatures.channel_facebook && hasFbConfigured.value;
}
if (key === 'email') {
return props.enabledFeatures.channel_email;
}
if (key === 'instagram') {
return (
props.enabledFeatures.channel_instagram && hasInstagramConfigured.value
);
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
'api',
'whatsapp',
'sms',
'telegram',
'line',
'instagram',
'voice',
].includes(key);
});
const isComingSoon = computed(() => {
const { key } = props.channel;
// Show "Coming Soon" only if the channel is marked as coming soon
// and the corresponding feature flag is not enabled yet.
return ['voice'].includes(key) && !isActive.value;
});
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
}
};
</script>
<template>
<ChannelSelector
:class="{ inactive: !isActive }"
:title="channel.name"
:src="getChannelThumbnail()"
:title="channel.title"
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:disabled="!isActive"
@click="onItemClick"
/>
</template>
@@ -13,7 +13,7 @@ defineProps({
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
/>
<span class="truncate">
{{ inbox.name }}
@@ -95,6 +95,10 @@ export default {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
@@ -121,6 +125,7 @@ export default {
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -347,6 +352,15 @@ export default {
sm
@click="$emit('selectWhatsappTemplate')"
/>
<NextButton
v-if="enableContentTemplates"
v-tooltip.top-end="'Content Templates'"
icon="i-ph-whatsapp-logo"
slate
faded
sm
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
@@ -361,7 +375,7 @@ export default {
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-n-slate-12">
@@ -0,0 +1,97 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TemplatesPicker from './ContentTemplatesPicker.vue';
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
const { t } = useI18n();
const selectedContentTemplate = ref(null);
const localShow = computed({
get() {
return props.show;
},
set(value) {
emit('update:show', value);
},
});
const modalHeaderContent = computed(() => {
return selectedContentTemplate.value
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
templateName: selectedContentTemplate.value.friendly_name,
})
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
});
const pickTemplate = template => {
selectedContentTemplate.value = template;
};
const onResetTemplate = () => {
selectedContentTemplate.value = null;
};
const onSendMessage = message => {
emit('onSend', message);
};
const onClose = () => {
emit('cancel');
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
<woot-modal-header
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
:header-content="modalHeaderContent"
/>
<div class="px-8 py-6 row">
<TemplatesPicker
v-if="!selectedContentTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedContentTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<div class="flex gap-2 mt-6">
<Button
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
color="slate"
variant="faded"
class="flex-1"
@click="resetTemplate"
/>
<Button
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
class="flex-1"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</TemplateParser>
</div>
</woot-modal>
</template>
@@ -0,0 +1,169 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const twilioTemplates = computed(() => {
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
return inbox?.content_templates?.templates || [];
});
const filteredTemplateMessages = computed(() =>
twilioTemplates.value.filter(
template =>
template.friendly_name
.toLowerCase()
.includes(query.value.toLowerCase()) && template.status === 'approved'
)
);
const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div
v-for="(template, i) in filteredTemplateMessages"
:key="template.content_sid"
>
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.friendly_name }}
</p>
<div class="flex gap-2">
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ getTemplateType(template) }}
</span>
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
}}
</span>
</div>
</div>
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
</p>
<p class="text-sm label-body">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<div class="flex justify-between items-center mt-3">
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p>
<p class="text-sm">{{ template.category || 'utility' }}</p>
</div>
<div class="text-xs text-n-slate-11">
{{ new Date(template.created_at).toLocaleDateString() }}
</div>
</div>
</div>
</button>
<hr
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && twilioTemplates.length">
<p>
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!twilioTemplates.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.label-body {
font-family: monospace;
}
</style>
@@ -250,7 +250,6 @@ const deleteConversation = () => {
:src="currentContact.thumbnail"
:size="32"
:status="currentContact.availability_status"
:inbox="inbox"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -27,6 +27,7 @@ import {
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
@@ -52,8 +53,8 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
Banner,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
@@ -61,6 +62,7 @@ export default {
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
},
@@ -109,6 +111,7 @@ export default {
toEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
showContentTemplatesModal: false,
updateEditorSelectionWith: '',
undefinedVariableMessage: '',
showMentions: false,
@@ -155,38 +158,12 @@ export default {
return false;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
showSelfAssignBanner() {
if (this.message !== '' && !this.isOnPrivateNote) {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
}
return false;
},
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
},
showContentTemplates() {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
@@ -659,28 +636,11 @@ export default {
hideWhatsappTemplatesModal() {
this.showWhatsAppTemplatesModal = false;
},
onClickSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
openContentTemplateModal() {
this.showContentTemplatesModal = true;
},
hideContentTemplatesModal() {
this.showContentTemplatesModal = false;
},
confirmOnSendReply() {
if (this.isReplyButtonDisabled) {
@@ -774,6 +734,13 @@ export default {
});
this.hideWhatsappTemplatesModal();
},
async onSendContentTemplateReply(messagePayload) {
this.sendMessage({
conversationId: this.currentChat.id,
...messagePayload,
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
@@ -1099,16 +1066,7 @@ export default {
</script>
<template>
<Banner
v-if="showSelfAssignBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg banner--self-assign"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
<ReplyTopPanel
:mode="replyType"
@@ -1217,6 +1175,7 @@ export default {
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
@@ -1239,6 +1198,7 @@ export default {
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@@ -1251,6 +1211,14 @@ export default {
@cancel="hideWhatsappTemplatesModal"
/>
<ContentTemplates
:inbox-id="inbox.id"
:show="showContentTemplatesModal"
@close="hideContentTemplatesModal"
@on-send="onSendContentTemplateReply"
@cancel="hideContentTemplatesModal"
/>
<woot-confirm-modal
ref="confirmDialog"
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
@@ -1264,10 +1232,6 @@ export default {
@apply mb-0;
}
.banner--self-assign {
@apply py-2;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
@@ -0,0 +1,129 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import Banner from 'dashboard/components/ui/Banner.vue';
const props = defineProps({
message: {
type: String,
default: '',
},
isOnPrivateNote: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const currentChat = useMapGetter('getSelectedChat');
const currentUser = useMapGetter('getCurrentUser');
const assignedAgent = computed({
get() {
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,
agentId,
});
},
});
const isUserTyping = computed(
() => props.message !== '' && !props.isOnPrivateNote
);
const isUnassigned = computed(() => !assignedAgent.value);
const isAssignedToOtherAgent = computed(
() => assignedAgent.value?.id !== currentUser.value?.id
);
const showSelfAssignBanner = computed(() => {
return (
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
);
});
const showBotHandoffBanner = computed(
() =>
isUserTyping.value &&
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
);
const botHandoffActionLabel = computed(() => {
return assignedAgent.value?.id === currentUser.value?.id
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
: t('CONVERSATION.BOT_HANDOFF_ACTION');
});
const selfAssignConversation = async () => {
const { avatar_url, ...rest } = currentUser.value || {};
assignedAgent.value = { ...rest, thumbnail: avatar_url };
};
const needsAssignmentToCurrentUser = computed(() => {
return isUnassigned.value || isAssignedToOtherAgent.value;
});
const onClickSelfAssign = async () => {
try {
await selfAssignConversation();
useAlert(t('CONVERSATION.CHANGE_AGENT'));
} catch (error) {
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
}
};
const reopenConversation = async () => {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value?.id,
status: wootConstants.STATUS_TYPE.OPEN,
});
};
const onClickBotHandoff = async () => {
try {
await reopenConversation();
if (needsAssignmentToCurrentUser.value) {
await selfAssignConversation();
}
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
}
};
</script>
<template>
<Banner
v-if="showSelfAssignBanner && !showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
has-action-button
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
@primary-action="onClickSelfAssign"
/>
<Banner
v-if="showBotHandoffBanner"
action-button-variant="ghost"
color-scheme="secondary"
class="mx-2 mb-2 rounded-lg !py-2"
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
has-action-button
:action-button-label="botHandoffActionLabel"
@primary-action="onClickBotHandoff"
/>
</template>
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
return null;
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
@@ -7,6 +7,7 @@ import {
hasValidAvatarUrl,
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
});
});
describe('extractFilenameFromUrl', () => {
it('should extract filename from a valid URL', () => {
expect(
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
).toBe('file.jpg');
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
'image.png'
);
expect(
extractFilenameFromUrl(
'https://example.com/folder/document.pdf?query=1'
)
).toBe('document.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.txt#section')
).toBe('file.txt');
});
it('should handle URLs without filename', () => {
expect(extractFilenameFromUrl('https://example.com/')).toBe(
'https://example.com/'
);
expect(extractFilenameFromUrl('https://example.com')).toBe(
'https://example.com'
);
});
it('should handle invalid URLs gracefully', () => {
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
});
it('should handle edge cases', () => {
expect(extractFilenameFromUrl('')).toBe('');
expect(extractFilenameFromUrl(null)).toBe(null);
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
expect(extractFilenameFromUrl(123)).toBe(123);
});
it('should handle URLs with query parameters and fragments', () => {
expect(
extractFilenameFromUrl(
'https://example.com/file.jpg?size=large&format=png'
)
).toBe('file.jpg');
expect(
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
).toBe('file.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
).toBe('file.doc');
});
});
});
@@ -612,6 +612,15 @@
"SEND_MESSAGE": "Send message"
}
},
"TWILIO_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"BACK": "Go back",
"SEND_MESSAGE": "Send message"
}
},
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
@@ -48,7 +48,8 @@
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link",
"BLOCKED": "Blocked"
"BLOCKED": "Blocked",
"LABELS": "Labels"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
@@ -0,0 +1,51 @@
{
"CONTENT_TEMPLATES": {
"MODAL": {
"TITLE": "Twilio Templates",
"SUBTITLE": "Select the Twilio template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_CONTENT": "No content",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
},
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"TEXT": "Text"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"MEDIA_URL_LABEL": "Enter full media URL",
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
},
"FORM": {
"BACK_BUTTON": "Back",
"SEND_MESSAGE_BUTTON": "Send Message"
}
}
}
@@ -35,6 +35,11 @@
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You wont be able to send messages from this conversation anymore.",
@@ -807,6 +807,58 @@
"ERROR_MESSAGE": "Unable to update portal"
}
}
},
"PDF_UPLOAD": {
"TITLE": "Upload PDF Document",
"DESCRIPTION": "Upload a PDF document to automatically generate FAQs using AI",
"DRAG_DROP_TEXT": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"ADDITIONAL_CONTEXT_LABEL": "Additional Context (Optional)",
"ADDITIONAL_CONTEXT_PLACEHOLDER": "Provide any additional context or instructions for FAQ generation...",
"UPLOADING": "Uploading...",
"UPLOAD": "Upload & Process",
"CANCEL": "Cancel",
"ERROR_INVALID_TYPE": "Please select a valid PDF file",
"ERROR_FILE_TOO_LARGE": "File size must be less than 512MB",
"ERROR_UPLOAD_FAILED": "Failed to upload PDF. Please try again."
},
"PDF_DOCUMENTS": {
"TITLE": "PDF Documents",
"DESCRIPTION": "Manage uploaded PDF documents and generate FAQs from them",
"UPLOAD_PDF": "Upload PDF",
"UPLOAD_FIRST_PDF": "Upload your first PDF",
"UPLOADED_BY": "Uploaded by",
"GENERATE_FAQS": "Generate FAQs",
"GENERATING": "Generating...",
"CONFIRM_DELETE": "Are you sure you want to delete {filename}?",
"EMPTY_STATE": {
"TITLE": "No PDF documents yet",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQs using AI"
},
"STATUS": {
"UPLOADED": "Ready",
"PROCESSING": "Processing",
"PROCESSED": "Completed",
"FAILED": "Failed"
}
},
"CONTENT_GENERATION": {
"TITLE": "Content Generation",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQ content using AI",
"UPLOAD_TITLE": "Upload PDF Document",
"DRAG_DROP": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"UPLOADING": "Processing document...",
"UPLOAD_SUCCESS": "Document processed successfully!",
"UPLOAD_ERROR": "Failed to upload document. Please try again.",
"INVALID_FILE_TYPE": "Please select a valid PDF file",
"FILE_TOO_LARGE": "File size must be less than 512MB",
"GENERATED_CONTENT": "Generated FAQ Content",
"PUBLISH_SELECTED": "Publish Selected",
"PUBLISHING": "Publishing...",
"FROM_DOCUMENT": "From document",
"NO_CONTENT": "No generated content available. Upload a PDF document to get started.",
"LOADING": "Loading generated content..."
}
}
}
@@ -424,7 +424,51 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below.",
"TITLE_NEXT": "Complete the setup",
"TITLE_FINISH": "Voilà!",
"CHANNEL": {
"WEBSITE": {
"TITLE": "Website",
"DESCRIPTION": "Create a live-chat widget"
},
"FACEBOOK": {
"TITLE": "Facebook",
"DESCRIPTION": "Connect your Facebook page"
},
"WHATSAPP": {
"TITLE": "WhatsApp",
"DESCRIPTION": "Support your customers on WhatsApp"
},
"EMAIL": {
"TITLE": "Email",
"DESCRIPTION": "Connect with Gmail, Outlook, or other providers"
},
"SMS": {
"TITLE": "SMS",
"DESCRIPTION": "Integrate SMS channel with Twilio or bandwidth"
},
"API": {
"TITLE": "API",
"DESCRIPTION": "Make a custom channel using our API"
},
"TELEGRAM": {
"TITLE": "Telegram",
"DESCRIPTION": "Configure Telegram channel using Bot token"
},
"LINE": {
"TITLE": "Line",
"DESCRIPTION": "Integrate your Line channel"
},
"INSTAGRAM": {
"TITLE": "Instagram",
"DESCRIPTION": "Connect your instagram account"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
}
}
},
"AGENTS": {
"TITLE": "Agents",
@@ -871,9 +915,18 @@
"SCRIPT_SETTINGS": "\n window.chatwootSettings = {options};"
},
"EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft",
"GOOGLE": "Google",
"OTHER_PROVIDERS": "Other Providers"
"MICROSOFT": {
"TITLE": "Microsoft",
"DESCRIPTION": "Connect with Microsoft"
},
"GOOGLE": {
"TITLE": "Google",
"DESCRIPTION": "Connect with Google"
},
"OTHER_PROVIDERS": {
"TITLE": "Other Providers",
"DESCRIPTION": "Connect with Other Providers"
}
},
"CHANNELS": {
"MESSENGER": "Messenger",
@@ -35,6 +35,7 @@ import signup from './signup.json';
import sla from './sla.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
export default {
...advancedFilters,
@@ -74,4 +75,5 @@ export default {
...sla,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
};
@@ -701,11 +701,28 @@
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"TYPE": {
"LABEL": "Document Type",
"URL": "URL",
"PDF": "PDF File"
},
"URL": {
"LABEL": "URL",
"PLACEHOLDER": "Enter the URL of the document",
"ERROR": "Please provide a valid URL for the document"
},
"PDF_FILE": {
"LABEL": "PDF File",
"CHOOSE_FILE": "Choose PDF file",
"ERROR": "Please select a PDF file",
"HELP_TEXT": "Maximum file size: 10MB",
"INVALID_TYPE": "Please select a valid PDF file",
"TOO_LARGE": "File size exceeds 10MB limit"
},
"NAME": {
"LABEL": "Document Name (Optional)",
"PLACEHOLDER": "Enter a name for the document"
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select the assistant",
@@ -1,5 +1,6 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
@@ -84,6 +85,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
@@ -127,6 +136,10 @@ export const filterAttributeGroups = [
key: 'blocked',
i18nKey: 'BLOCKED',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
],
},
];
@@ -94,12 +94,7 @@ const loadMoreNotifications = () => {
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
const { id, primaryActorId, primaryActorType } = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
@@ -63,9 +63,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="addAgents()">
<div class="w-full">
<PageHeader
@@ -1,83 +1,108 @@
<script>
import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import { useBranding } from 'shared/composables/useBranding';
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
export default {
components: {
ChannelItem,
PageHeader,
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
enabledFeatures: {},
};
},
computed: {
account() {
return this.$store.getters['accounts/getAccount'](this.accountId);
import { useAccount } from 'dashboard/composables/useAccount';
import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
const { t } = useI18n();
const router = useRouter();
const { accountId, currentAccount } = useAccount();
const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const channelList = computed(() => {
const { apiChannelName } = globalConfig.value;
return [
{
key: 'website',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.DESCRIPTION'),
icon: 'i-woot-website',
},
channelList() {
const { apiChannelName, apiChannelThumbnail } = this.globalConfig;
return [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Messenger' },
{ key: 'whatsapp', name: 'WhatsApp' },
{ key: 'sms', name: 'SMS' },
{ key: 'email', name: 'Email' },
{
key: 'api',
name: apiChannelName || 'API',
thumbnail: apiChannelThumbnail,
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
{
key: 'facebook',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.DESCRIPTION'),
icon: 'i-woot-messenger',
},
...mapGetters({
accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}),
},
mounted() {
this.initializeEnabledFeatures();
},
methods: {
async initializeEnabledFeatures() {
this.enabledFeatures = this.account.features;
{
key: 'whatsapp',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.DESCRIPTION'),
icon: 'i-woot-whatsapp',
},
initChannelAuth(channel) {
const params = {
sub_page: channel,
accountId: this.accountId,
};
router.push({ name: 'settings_inboxes_page_channel', params });
{
key: 'sms',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.DESCRIPTION'),
icon: 'i-woot-sms',
},
},
{
key: 'email',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.EMAIL.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.EMAIL.DESCRIPTION'),
icon: 'i-woot-mail',
},
{
key: 'api',
title: apiChannelName || t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.DESCRIPTION'),
icon: 'i-woot-api',
},
{
key: 'telegram',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.DESCRIPTION'),
icon: 'i-woot-telegram',
},
{
key: 'line',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.DESCRIPTION'),
icon: 'i-woot-line',
},
{
key: 'instagram',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-ri-phone-fill',
},
];
});
const initializeEnabledFeatures = async () => {
enabledFeatures.value = currentAccount.value.features;
};
const initChannelAuth = channel => {
const params = {
sub_page: channel,
accountId: accountId.value,
};
router.push({ name: 'settings_inboxes_page_channel', params });
};
onMounted(() => {
initializeEnabledFeatures();
});
</script>
<template>
<div
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
/>
<div class="w-full p-8 overflow-auto">
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
>
<ChannelItem
v-for="channel in channelList"
@@ -168,9 +168,7 @@ onMounted(() => {
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<div class="w-full h-full col-span-6 p-6 overflow-auto">
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
@@ -1,55 +1,106 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
export default {
setup() {
const { replaceInstallationName } = useBranding();
import PageHeader from '../SettingsSubPageHeader.vue';
import Icon from 'next/icon/Icon.vue';
const { t } = useI18n();
const route = useRoute();
const { replaceInstallationName } = useBranding();
const globalConfig = useMapGetter('globalConfig/get');
const ALL_CHANNEL_ICONS = [
'i-woot-line',
'i-woot-facebook',
'i-woot-whatsapp',
'i-woot-instagram',
'i-woot-messenger',
'i-woot-website',
'i-woot-mail',
'i-woot-sms',
'i-woot-telegram',
'i-woot-api',
'i-woot-twilio',
'i-woot-gmail',
'i-woot-outlook',
];
const createFlowSteps = computed(() => {
const steps = ['CHANNEL', 'INBOX', 'AGENT', 'FINISH'];
const routes = {
CHANNEL: 'settings_inbox_new',
INBOX: 'settings_inboxes_page_channel',
AGENT: 'settings_inboxes_add_agents',
FINISH: 'settings_inbox_finish',
};
return steps.map(step => {
return {
replaceInstallationName,
title: t(`INBOX_MGMT.CREATE_FLOW.${step}.TITLE`),
body: t(`INBOX_MGMT.CREATE_FLOW.${step}.BODY`),
route: routes[step],
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
createFlowSteps() {
const steps = ['CHANNEL', 'INBOX', 'AGENT', 'FINISH'];
});
});
const routes = {
CHANNEL: 'settings_inbox_new',
INBOX: 'settings_inboxes_page_channel',
AGENT: 'settings_inboxes_add_agents',
FINISH: 'settings_inbox_finish',
};
const isFirstStep = computed(() => {
return route.name === 'settings_inbox_new';
});
return steps.map(step => {
return {
title: this.$t(`INBOX_MGMT.CREATE_FLOW.${step}.TITLE`),
body: this.$t(`INBOX_MGMT.CREATE_FLOW.${step}.BODY`),
route: routes[step],
};
});
},
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.replaceInstallationName(item.body),
}));
},
},
};
const isFinishStep = computed(() => {
return route.name === 'settings_inbox_finish';
});
const pageTitle = computed(() => {
if (isFirstStep.value) {
return t('INBOX_MGMT.ADD.AUTH.TITLE');
}
if (isFinishStep.value) {
return t('INBOX_MGMT.ADD.AUTH.TITLE_FINISH');
}
return t('INBOX_MGMT.ADD.AUTH.TITLE_NEXT');
});
const items = computed(() => {
return createFlowSteps.value.map(item => ({
...item,
body: replaceInstallationName(item.body),
}));
});
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full">
<woot-wizard
class="hidden md:block col-span-2"
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6">
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
<div class="hidden lg:grid grid-cols-1 lg:grid-cols-8 items-center gap-2">
<div class="col-span-2 w-full" />
<div class="flex items-center gap-2 col-span-6 ltr:ml-8 rtl:mr-8">
<div
v-for="icon in ALL_CHANNEL_ICONS"
:key="icon"
class="size-8 bg-n-alpha-2 flex items-center flex-shrink-0 justify-center rounded-full"
>
<Icon :icon="icon" class="size-4 text-n-slate-10" />
</div>
</div>
</div>
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6 overflow-hidden">
<router-view />
</div>
</div>
</div>
</template>
@@ -116,7 +116,7 @@ const openDelete = inbox => {
v-else
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<ChannelIcon class="size-5" :inbox="inbox" />
<ChannelIcon class="size-5 text-n-slate-10" :inbox="inbox" />
</div>
<div>
<span class="block font-medium capitalize">
@@ -65,9 +65,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.API_CHANNEL.DESC')"
@@ -20,22 +20,25 @@ const isAChatwootInstance = getters['globalConfig/isAChatwootInstance'];
const emailProviderList = computed(() => {
return [
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT.DESCRIPTION'),
isEnabled: !!globalConfig.value.azureAppId,
key: 'microsoft',
src: '/assets/images/dashboard/channels/microsoft.png',
icon: 'i-woot-outlook',
},
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.DESCRIPTION'),
isEnabled: !!window.chatwootConfig.googleOAuthClientId,
key: 'google',
src: '/assets/images/dashboard/channels/google.png',
icon: 'i-woot-gmail',
},
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS'),
title: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.OTHER_PROVIDERS.DESCRIPTION'),
isEnabled: true,
key: 'other_provider',
src: '/assets/images/dashboard/channels/email.png',
icon: 'i-woot-mail',
},
].filter(providerConfig => {
if (isAChatwootInstance.value) {
@@ -53,22 +56,20 @@ function onClick(emailProvider) {
</script>
<template>
<div
v-if="!provider"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div v-if="!provider" class="h-full w-full p-6 col-span-6">
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_PROVIDER.DESCRIPTION')"
/>
<div class="grid max-w-3xl grid-cols-4 mx-0 mt-6">
<div class="grid max-w-3xl grid-cols-4 gap-6 mx-0 mt-6">
<ChannelSelector
v-for="emailProvider in emailProviderList"
:key="emailProvider.key"
:class="{ inactive: !emailProvider.isEnabled }"
:title="emailProvider.title"
:src="emailProvider.src"
:description="emailProvider.description"
:icon="emailProvider.icon"
:disabled="!emailProvider.isEnabled"
@click="() => onClick(emailProvider)"
/>
</div>
@@ -206,16 +206,14 @@ export default {
</script>
<template>
<div
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<div class="w-full h-full col-span-6 p-6 overflow-auto">
<div
v-if="!hasLoginStarted"
class="flex flex-col items-center justify-center h-full text-center"
>
<a href="#" @click="startLogin()">
<img
class="w-auto h-10"
class="w-auto h-10 rounded-md"
src="~dashboard/assets/images/channels/facebook_login.png"
alt="Facebook-logo"
/>
@@ -62,9 +62,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-background h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="h-full p-6 w-full max-w-full flex-shrink-0 flex-grow-0">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
@@ -75,7 +73,7 @@ export default {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -71,9 +71,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.LINE_CHANNEL.DESC')"
@@ -18,9 +18,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.SMS.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.SMS.DESC')"
@@ -65,9 +65,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.DESC')"
@@ -30,9 +30,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<div class="login-init h-full text-center">
<form @submit.prevent="requestAuthorization">
<NextButton
@@ -93,9 +93,7 @@ async function createChannel() {
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<PageHeader
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
@@ -78,9 +78,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')"
@@ -7,6 +7,7 @@ import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
@@ -45,16 +46,16 @@ const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
key: PROVIDER_TYPES.WHATSAPP,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: '/assets/images/dashboard/channels/whatsapp.png',
icon: 'i-woot-whatsapp',
},
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
key: PROVIDER_TYPES.TWILIO,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: '/assets/images/dashboard/channels/twilio.png',
icon: 'i-woot-twilio',
},
]);
@@ -92,9 +93,7 @@ const shouldShowCloudWhatsapp = provider => {
</script>
<template>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<div class="overflow-auto col-span-6 p-6 w-full h-full">
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
@@ -106,51 +105,30 @@ const shouldShowCloudWhatsapp = provider => {
</div>
<div class="flex gap-6 justify-start">
<div
<ChannelSelector
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<div class="flex justify-start mb-5">
<div
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
:key="provider.key"
:title="provider.title"
:description="provider.description"
:icon="provider.icon"
@click="selectProvider(provider.key)"
/>
</div>
</div>
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</template>
@@ -70,9 +70,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.DESC')"
@@ -60,9 +60,7 @@ async function requestAuthorization() {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<SettingsSubPageHeader
:header-title="title"
:header-content="description"
@@ -125,6 +125,19 @@ export default {
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
v-if="isATwilioWhatsAppChannel"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
@@ -88,9 +88,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
@@ -37,9 +37,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.DESC')"
@@ -23,8 +23,15 @@ export default {
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full px-5 flex-1">
<woot-wizard class="hidden md:block col-span-2" :items="items" />
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:items="items"
/>
<router-view />
</div>
</div>
</template>
@@ -103,9 +103,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-8 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
@@ -57,9 +57,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-8 col-span-6">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.DESC')"
@@ -27,8 +27,15 @@ export default {
</script>
<template>
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full px-5 flex-1">
<woot-wizard class="hidden md:block col-span-2" :items="items" />
<router-view />
<div class="mx-2 flex flex-col gap-6 mb-8">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:items="items"
/>
<router-view />
</div>
</div>
</template>
@@ -11,9 +11,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div class="h-full w-full p-6 col-span-6">
<EmptyState
:title="$t('TEAMS_SETTINGS.FINISH.TITLE')"
:message="$t('TEAMS_SETTINGS.FINISH.MESSAGE')"
@@ -38,4 +38,7 @@ export const getters = {
getHasUnreadNotifications: $state => {
return $state.meta.unreadCount > 0;
},
getUnreadCount: $state => {
return $state.meta.unreadCount;
},
};
@@ -33,13 +33,15 @@ export default {
brandRedirectURL() {
try {
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
referrerHost ? 'widget_branding' : 'survey_branding'
}`;
const url = new URL(this.globalConfig.widgetBrandURL);
if (referrerHost) {
return `${baseURL}&utm_referrer=${referrerHost}`;
url.searchParams.set('utm_source', referrerHost);
url.searchParams.set('utm_medium', 'widget');
} else {
url.searchParams.set('utm_medium', 'survey');
}
return baseURL;
url.searchParams.set('utm_campaign', 'branding');
return url.toString();
} catch (e) {
// Suppressing the error as getter is not defined in some cases
}
@@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = {
location: 'location',
fallback: 'link',
};
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
};
+7 -2
View File
@@ -2,11 +2,16 @@
* Writes a text string to the system clipboard.
*
* @async
* @param {string} text text to be written to the clipboard
* @param {string} data text to be written to the clipboard
* @throws {Error} unable to copy text to clipboard
*/
export const copyTextToClipboard = async text => {
export const copyTextToClipboard = async data => {
try {
const text =
typeof data === 'object' && data !== null
? JSON.stringify(data, null, 2)
: String(data ?? '');
await navigator.clipboard.writeText(text);
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
@@ -0,0 +1,38 @@
/**
* Document Helper - utilities for document display and formatting
*/
// Constants for document processing
const PDF_PREFIX = 'PDF:';
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
/**
* Checks if a document is a PDF based on its external link
* @param {string} externalLink - The external link string
* @returns {boolean} True if the document is a PDF
*/
export const isPdfDocument = externalLink => {
if (!externalLink) return false;
return externalLink.startsWith(PDF_PREFIX);
};
/**
* Formats the display link for documents
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
* For regular URLs: returns as-is
*
* @param {string} externalLink - The external link string
* @returns {string} Formatted display link
*/
export const formatDocumentLink = externalLink => {
if (!externalLink) return '';
if (isPdfDocument(externalLink)) {
// Remove 'PDF:' prefix
const fullName = externalLink.substring(PDF_PREFIX.length);
// Remove timestamp suffix if present
return fullName.replace(TIMESTAMP_PATTERN, '');
}
return externalLink;
};
@@ -0,0 +1,174 @@
import { copyTextToClipboard } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
describe('copyTextToClipboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('with string input', () => {
it('copies plain text string to clipboard', async () => {
const text = 'Hello World';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('copies empty string to clipboard', async () => {
const text = '';
await copyTextToClipboard(text);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with number input', () => {
it('converts number to string', async () => {
await copyTextToClipboard(42);
expect(mockWriteText).toHaveBeenCalledWith('42');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts zero to string', async () => {
await copyTextToClipboard(0);
expect(mockWriteText).toHaveBeenCalledWith('0');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with boolean input', () => {
it('converts true to string', async () => {
await copyTextToClipboard(true);
expect(mockWriteText).toHaveBeenCalledWith('true');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts false to string', async () => {
await copyTextToClipboard(false);
expect(mockWriteText).toHaveBeenCalledWith('false');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with null/undefined input', () => {
it('converts null to empty string', async () => {
await copyTextToClipboard(null);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('converts undefined to empty string', async () => {
await copyTextToClipboard(undefined);
expect(mockWriteText).toHaveBeenCalledWith('');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('with object input', () => {
it('stringifies simple object with proper formatting', async () => {
const obj = { name: 'John', age: 30 };
await copyTextToClipboard(obj);
const expectedJson = JSON.stringify(obj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies nested object with proper formatting', async () => {
const nestedObj = {
severity: {
user_id: 1181505,
user_name: 'test',
server_name: '[1253]test1253',
},
};
await copyTextToClipboard(nestedObj);
const expectedJson = JSON.stringify(nestedObj, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies array with proper formatting', async () => {
const arr = [1, 2, { name: 'test' }];
await copyTextToClipboard(arr);
const expectedJson = JSON.stringify(arr, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty object', async () => {
const obj = {};
await copyTextToClipboard(obj);
expect(mockWriteText).toHaveBeenCalledWith('{}');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('stringifies empty array', async () => {
const arr = [];
await copyTextToClipboard(arr);
expect(mockWriteText).toHaveBeenCalledWith('[]');
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('throws error when clipboard API fails', async () => {
const error = new Error('Clipboard access denied');
mockWriteText.mockRejectedValueOnce(error);
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard: Clipboard access denied'
);
});
it('handles clipboard API not available', async () => {
// Temporarily remove clipboard API
const originalClipboard = navigator.clipboard;
delete navigator.clipboard;
await expect(copyTextToClipboard('test')).rejects.toThrow(
'Unable to copy text to clipboard:'
);
// Restore clipboard API
navigator.clipboard = originalClipboard;
});
});
describe('edge cases', () => {
it('handles Date objects', async () => {
const date = new Date('2023-01-01T00:00:00.000Z');
await copyTextToClipboard(date);
const expectedJson = JSON.stringify(date, null, 2);
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
it('handles functions by converting to string', async () => {
const func = () => 'test';
await copyTextToClipboard(func);
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,111 @@
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
describe('documentHelper', () => {
describe('#isPdfDocument', () => {
it('returns true for PDF documents', () => {
expect(isPdfDocument('PDF:document.pdf')).toBe(true);
expect(isPdfDocument('PDF:my-file_20241227123045.pdf')).toBe(true);
expect(isPdfDocument('PDF:report with spaces_20241227123045.pdf')).toBe(
true
);
});
it('returns false for regular URLs', () => {
expect(isPdfDocument('https://example.com')).toBe(false);
expect(isPdfDocument('http://docs.example.com/file.pdf')).toBe(false);
expect(isPdfDocument('ftp://files.example.com/document.pdf')).toBe(false);
});
it('returns false for empty or null values', () => {
expect(isPdfDocument('')).toBe(false);
expect(isPdfDocument(null)).toBe(false);
expect(isPdfDocument(undefined)).toBe(false);
});
it('returns false for strings that contain PDF but do not start with PDF:', () => {
expect(isPdfDocument('document PDF:file.pdf')).toBe(false);
expect(isPdfDocument('My PDF:file.pdf')).toBe(false);
});
});
describe('#formatDocumentLink', () => {
describe('PDF documents', () => {
it('removes PDF: prefix from PDF documents', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:my-file.pdf')).toBe('my-file.pdf');
});
it('removes timestamp suffix from PDF documents', () => {
expect(formatDocumentLink('PDF:document_20241227123045.pdf')).toBe(
'document.pdf'
);
expect(formatDocumentLink('PDF:report_20231215094530.pdf')).toBe(
'report.pdf'
);
});
it('handles PDF documents with spaces in filename', () => {
expect(formatDocumentLink('PDF:my document_20241227123045.pdf')).toBe(
'my document.pdf'
);
expect(
formatDocumentLink('PDF:Annual Report 2024_20241227123045.pdf')
).toBe('Annual Report 2024.pdf');
});
it('handles PDF documents without timestamp suffix', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:simple-file.pdf')).toBe(
'simple-file.pdf'
);
});
it('handles PDF documents with partial timestamp patterns', () => {
expect(formatDocumentLink('PDF:document_202412.pdf')).toBe(
'document_202412.pdf'
);
expect(formatDocumentLink('PDF:file_123.pdf')).toBe('file_123.pdf');
});
it('handles edge cases with timestamp pattern', () => {
expect(
formatDocumentLink('PDF:doc_20241227123045_final_20241227123045.pdf')
).toBe('doc_20241227123045_final.pdf');
});
});
describe('Regular URLs', () => {
it('returns regular URLs unchanged', () => {
expect(formatDocumentLink('https://example.com')).toBe(
'https://example.com'
);
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
'http://docs.example.com/api'
);
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
'https://github.com/user/repo'
);
});
it('handles URLs with query parameters', () => {
expect(formatDocumentLink('https://example.com?param=value')).toBe(
'https://example.com?param=value'
);
expect(
formatDocumentLink(
'https://api.example.com/docs?version=v1&format=json'
)
).toBe('https://api.example.com/docs?version=v1&format=json');
});
it('handles URLs with fragments', () => {
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
'https://example.com/docs#section1'
);
});
});
});
});
@@ -0,0 +1,47 @@
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
Account.feature_assignment_v2.find_each do |account|
if should_skip_auto_assignment?(account)
Rails.logger.info("Skipping auto assignment for account #{account.id}")
next
end
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
process_assignment(inbox)
end
end
end
private
def process_assignment(inbox)
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
if allowed_agent_ids.blank?
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
return
end
assign_conversations(inbox, allowed_agent_ids)
end
def assign_conversations(inbox, allowed_agent_ids)
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
unassigned_conversations.find_each do |conversation|
::AutoAssignment::AgentAssignmentService.new(
conversation: conversation,
allowed_agent_ids: allowed_agent_ids
).perform
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
end
end
def should_skip_auto_assignment?(account)
return false unless ChatwootApp.chatwoot_cloud?
default_plan?(account)
end
end
@@ -1,10 +1,22 @@
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
def account_deletion(account, reason = 'manual_deletion')
subject = 'Your account has been marked for deletion'
def account_deletion_user_initiated(account, reason)
subject = 'Your Chatwoot account deletion has been scheduled'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def account_deletion_for_inactivity(account, reason)
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
@@ -45,4 +57,14 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
send_notification(subject, action_url: action_url, meta: meta)
end
private
def format_deletion_date(deletion_date_str)
return 'Unknown' if deletion_date_str.blank?
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
rescue StandardError
'Unknown'
end
end
+18 -15
View File
@@ -17,21 +17,7 @@ class Twilio::TemplateSyncService
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
formatted_templates = @templates.map { |template| format_template(template) }
channel.update!(
content_templates: { templates: formatted_templates },
@@ -39,6 +25,23 @@ class Twilio::TemplateSyncService
)
end
def format_template(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),
types: template.types,
created_at: template.date_created,
updated_at: template.date_updated
}
end
def mark_templates_updated
channel.update!(content_templates_last_updated: Time.current)
end
@@ -1,16 +0,0 @@
<p>Hello,</p>
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
{% if meta.reason == 'manual_deletion' %}
<p>This action was requested by one of the administrators of your account.</p>
{% else %}
<p>Reason for deletion: {{ meta.reason }}</p>
{% endif %}
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
<p>Thank you,<br>
Team Chatwoot</p>
@@ -0,0 +1,21 @@
<p>Hello there,</p>
<p>We've noticed that your Chatwoot account <strong>{{ meta.account_name }}</strong> has been inactive for some time. Because of this, it's scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>How do I keep my account?</strong></p>
<p>Log in to your Chatwoot account before <strong>{{ meta.deletion_date }}</strong>. From your account settings, you can <a href="{{ action_url }}">cancel the deletion</a> and continue using your account.</p>
<p><strong>What happens if I don't cancel?</strong></p>
<p>Unless you cancel the account deletion before <strong>{{ meta.deletion_date }}</strong>, your account and all associated data — including conversations, contacts, reports, and settings — will be permanently deleted.</p>
<p><strong>Why are we doing this?</strong></p>
<p>To keep things secure and efficient, we regularly remove inactive accounts so our systems remain optimized for active teams.</p>
<p>If you have any questions, feel free to reach us at <a href="mailto:hello@chatwoot.com">hello@chatwoot.com</a>.</p>
<p>— The Chatwoot Team</p>
@@ -0,0 +1,16 @@
<p>Hello there,</p>
<p>An account administrator has requested deletion of the Chatwoot account <strong>{{ meta.account_name }}</strong>. The account is scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
<p><strong>What happens next?</strong></p>
<ul>
<li>The account will remain accessible until the scheduled deletion date.</li>
<li>After that, all data including conversations, contacts, integrations, and settings will be permanently removed.</li>
</ul>
<p>If you change your mind before the deletion date, you can <a href="{{ action_url }}">cancel this request</a> by visiting your account settings.</p>
<p>— The Chatwoot Team</p>
@@ -8,12 +8,12 @@
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
</p>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['BRAND_NAME'] %></a>
</p>
</div>
</div>
</footer>
+1
View File
@@ -198,6 +198,7 @@
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
deprecated: true
- name: advanced_search
display_name: Advanced Search
enabled: false
+7
View File
@@ -46,3 +46,10 @@ delete_accounts_job:
cron: '0 1 * * *'
class: 'Internal::DeleteAccountsJob'
queue: scheduled_jobs
# executed every 15 minutes
# to assign unassigned conversations for all inboxes
bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
+14 -2
View File
@@ -4,10 +4,22 @@ module Enterprise::Account
def manually_managed_features; end
def mark_for_deletion(reason = 'manual_deletion')
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
result = custom_attributes.merge!(
'marked_for_deletion_at' => 7.days.from_now.iso8601,
'marked_for_deletion_reason' => reason
) && save
# Send notification to admin users if the account was successfully marked for deletion
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
if result
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
if reason == 'manual_deletion'
mailer.account_deletion_user_initiated(self, reason).deliver_later
else
mailer.account_deletion_for_inactivity(self, reason).deliver_later
end
end
result
end
+1
View File
@@ -5,6 +5,7 @@ module Limits
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
+8
View File
@@ -0,0 +1,8 @@
require 'simplecov'
require 'simplecov_json_formatter'
# Configure SimpleCov to emit JSON for Qlty and HTML locally if needed
SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
SimpleCov.start 'rails' do
SimpleCov.coverage_dir 'coverage'
end
+14 -5
View File
@@ -229,18 +229,27 @@ RSpec.describe Account, type: :model do
describe '#mark_for_deletion' do
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
expect do
account.mark_for_deletion('test_reason')
account.mark_for_deletion('inactivity')
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
end
it 'sends a notification email to admin users' do
it 'sends a user-initiated deletion email when reason is manual_deletion' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('test_reason')
account.mark_for_deletion('manual_deletion')
end
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
mailer = double
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
expect(mailer).to receive(:deliver_later)
account.mark_for_deletion('inactivity')
end
it 'returns true when successful' do
+36
View File
@@ -291,4 +291,40 @@ describe PortalHelper do
end
end
end
describe '#generate_portal_brand_url' do
it 'builds URL with UTM params and referer host as source (happy path)' do
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
uri = URI.parse(result)
params = Rack::Utils.parse_query(uri.query)
expect(uri.scheme).to eq('https')
expect(uri.host).to eq('brand.example.com')
expect(params['utm_medium']).to eq('helpcenter')
expect(params['utm_campaign']).to eq('branding')
expect(params['utm_source']).to eq('app.chatwoot.com')
end
it 'returns utm string when brand_url is nil or empty' do
expect(helper.generate_portal_brand_url(nil,
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
expect(helper.generate_portal_brand_url('',
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
end
it 'omits utm_source when referer is nil or invalid' do
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
expect(p1.key?('utm_source')).to be(false)
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
expect(p2.key?('utm_source')).to be(false)
expect(p2['utm_medium']).to eq('helpcenter')
expect(p2['utm_campaign']).to eq('branding')
end
end
end
@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Inboxes::BulkAutoAssignmentJob do
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
let(:assignment_service) { double }
describe '#perform' do
before do
allow(assignment_service).to receive(:perform)
end
context 'when inbox has inbox members' do
before do
create(:inbox_member, user: agent, inbox: inbox)
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'assigns unassigned conversations in enabled inboxes' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
).and_return(assignment_service)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
it 'skips inboxes with auto assignment disabled' do
inbox.update!(enable_auto_assignment: false)
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
context 'when account is on default plan in chatwoot cloud' do
before do
account.update!(custom_attributes: {})
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'skips auto assignment' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
context 'when inbox has no members' do
before do
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'does not assign conversations' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
described_class.perform_now
end
end
context 'when assignment_v2 feature is disabled' do
before do
account.disable_features!('assignment_v2')
end
it 'skips auto assignment' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
end
@@ -1,116 +1,46 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
include_context 'with smtp config'
let(:account) { create(:account, name: 'Test Account') }
let(:mailer) { described_class.with(account: account) }
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
account.save!
end
describe 'account_deletion' do
let(:reason) { 'manual_deletion' }
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
let(:deletion_date) { 7.days.from_now.iso8601 }
before do
account.update!(custom_attributes: {
'marked_for_deletion_at' => deletion_date,
'marked_for_deletion_reason' => reason
})
end
it 'renders the subject' do
expect(mail.subject).to eq('Your account has been marked for deletion')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the account name in the email body' do
expect(mail.body.encoded).to include(account.name)
end
it 'includes the deletion date in the email body' do
expect(mail.body.encoded).to include(deletion_date)
end
it 'includes a link to cancel the deletion' do
expect(mail.body.encoded).to include('Cancel Account Deletion')
end
context 'when reason is manual_deletion' do
it 'includes the administrator message' do
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
end
end
context 'when reason is not manual_deletion' do
let(:reason) { 'inactivity' }
it 'includes the reason directly' do
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
end
describe '#account_deletion_user_initiated' do
it 'sets the correct subject for user-initiated deletion' do
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
end
end
describe 'contact_import_complete' do
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Completed')
end
it 'renders the processed records' do
expect(mail.body.encoded).to include('Number of records imported: 8')
expect(mail.body.encoded).to include('Number of records failed: 2')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
describe '#account_deletion_for_inactivity' do
it 'sets the correct subject for system-initiated deletion' do
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
end
end
describe 'contact_import_failed' do
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Contact Import Failed')
describe '#format_deletion_date' do
it 'formats a valid date string' do
date_str = '2024-12-31T23:59:59Z'
formatted = described_class.new.send(:format_deletion_date, date_str)
expect(formatted).to eq('December 31, 2024')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'contact_export_complete' do
let!(:file_url) { 'http://test.com/test' }
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("Your contact's export file is available to download.")
it 'handles blank dates' do
formatted = described_class.new.send(:format_deletion_date, nil)
expect(formatted).to eq('Unknown')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
end
describe 'automation_rule_disabled' do
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
end
it 'renders the receiver email' do
expect(mail.to).to eq([admin.email])
end
it 'includes the rule name in the email body' do
expect(mail.body.encoded).to include('Test Rule')
it 'handles invalid dates' do
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
expect(formatted).to eq('Unknown')
end
end
end
-2
View File
@@ -1,7 +1,5 @@
require 'simplecov'
require 'webmock/rspec'
SimpleCov.start 'rails'
WebMock.disable_net_connect!(allow_localhost: true)
RSpec.configure do |config|
-2
View File
@@ -1,6 +1,4 @@
ENV['RAILS_ENV'] ||= 'test'
require 'simplecov'
SimpleCov.start 'rails'
require File.expand_path('../config/environment', __dir__)
require 'rails/test_help'
+67
View File
@@ -113,4 +113,71 @@ export const icons = {
width: 16,
height: 20,
},
/** Channels Starts */
website: {
body: `<path d="m3.023 1.664.126-.001.418-.001 1.286-.004h.34l1.596-.003q.92 0 1.84-.004l1.425-.003.85-.002a108 108 0 0 1 1.092-.003c.628-.004 1.163.04 1.706.388.554.593.645 1.218.64 2.004v.192l-.002.624v.435l-.004 1.14-.002 1.166-.006 2.283a4 4 0 0 1-.798-.15l-.182-.056-.193-.061-.2-.062-.634-.198-1.03-.32q-.088-.03-.18-.057l-.158-.05c-.39-.117-.764-.18-1.151-.013-.261.15-.403.338-.513.616-.059.432.047.817.18 1.224l.055.177q.086.278.175.554l.285.904.05.157.044.138c.031.127.031.127.031.361a1215 1215 0 0 1-4.146.014 337 337 0 0 1-1.753.005l-.68.001-.2.002c-.606-.002-1.133-.094-1.603-.493a1.78 1.78 0 0 1-.54-1.298l-.002-.703-.002-1.102-.002-1.481q0-.69-.003-1.378l-.002-1.185-.001-.707v-.666l-.002-.358c.004-.622.125-1.103.557-1.563.395-.347.776-.462 1.293-.463m-.648 1.121c-.48.604-.293.955-.293 1.875h11.309c.135-1.332.135-1.332-.235-1.816-.303-.28-.518-.3-.924-.302h-.12l-.687-.002-.943-.003h-.325q-.762-.003-1.525-.003-.88 0-1.76-.005L5.51 2.527l-.813-.003a99 99 0 0 0-1.044-.002c-.675-.009-.675-.009-1.279.263" fill="currentColor"/><path d="M10.52 9.406c.193.044.193.044.396.105l.112.034.621.189.534.163a164 164 0 0 0 1.211.369l.252.076.554.17c.507.179.507.179.597.359.034.365.034.365-.092.532-.176.14-.355.197-.567.258l-.251.079q-.196.06-.394.118c-.498.13-.498.13-.908.416a2 2 0 0 0-.132.413q-.057.177-.117.351-.09.28-.176.561l-.058.183-.052.164a.93.93 0 0 1-.3.382c-.205.03-.205.03-.41 0-.226-.176-.292-.356-.374-.625l-.037-.118-.116-.384-.081-.268-.17-.562q-.107-.36-.218-.717a136 136 0 0 1-.36-1.19q-.03-.105-.064-.212a2 2 0 0 1-.045-.611c.174-.272.34-.252.645-.235M6.184 3.162c.175.033.175.033.318.158.092.168.092.168.092.351-.068.194-.11.253-.293.344-.147.026-.147.026-.293 0-.136-.095-.136-.095-.235-.234-.035-.215-.028-.31.096-.49.139-.096.139-.096.315-.129m-1.172.033c.139.096.139.096.234.235.027.186.034.296-.066.458a.74.74 0 0 1-.461.128c-.172-.121-.172-.121-.293-.293 0-.191.026-.3.128-.462.161-.1.272-.092.458-.066m-1.231.059c.117.117.117.117.14.348-.006.168-.01.222-.114.359-.203.078-.345.046-.553-.004-.117-.117-.117-.117-.14-.348.006-.168.01-.222.114-.359.203-.078.345-.046.553.004" fill="currentColor"/>`,
width: 16,
height: 16,
},
line: {
body: `<g clip-path="url(#a)"><path d="M2.778.778a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2zm4 2.25c2.344 0 4.25 1.528 4.25 3.407 0 .751-.295 1.429-.912 2.096-.892 1.014-2.885 2.249-3.339 2.437-.453.189-.386-.12-.367-.226l.06-.36c.014-.107.03-.273-.013-.38-.048-.116-.236-.177-.375-.206-2.042-.267-3.554-1.677-3.554-3.361 0-1.879 1.907-3.407 4.25-3.407M4.27 5.443a.22.22 0 0 0-.223.22v1.669c0 .122.1.22.223.22h.844a.22.22 0 0 0 .223-.22c0-.121-.1-.22-.223-.22h-.621V5.664c0-.122-.1-.22-.223-.22m1.495 0a.22.22 0 0 0-.223.22v1.669c0 .122.1.22.223.22a.22.22 0 0 0 .223-.22V5.664c0-.122-.1-.22-.223-.22m.766 0a.22.22 0 0 0-.224.22v1.669c0 .122.101.22.224.22a.22.22 0 0 0 .223-.22V6.3l.865 1.164a.225.225 0 0 0 .25.077.22.22 0 0 0 .151-.209V5.664c0-.122-.1-.22-.222-.22a.22.22 0 0 0-.223.22v1.032l-.866-1.164a.23.23 0 0 0-.178-.089m2.011 0a.22.22 0 0 0-.222.221v1.668c0 .122.1.22.222.22h.845a.22.22 0 0 0 .223-.22c0-.121-.1-.22-.223-.22h-.622v-.394h.622a.22.22 0 0 0 .223-.22c0-.122-.1-.22-.223-.22h-.622v-.394h.622a.22.22 0 0 0 .223-.22c0-.121-.1-.22-.223-.22z" fill="currentColor"/></g><defs><clipPath id="a"><path fill="currentColor" d="M.333.333h13.333v13.333H.333z"/></clipPath></defs>`,
width: 13,
height: 13,
},
facebook: {
body: `<path d="M7 .333a6.667 6.667 0 0 1 1 13.258v-4.59h.666a1 1 0 0 0 0-2H8V5.665c0-.183.15-.333.333-.333h.333a1 1 0 0 0 0-2h-.333A2.333 2.333 0 0 0 6 5.668V7h-.667a1 1 0 1 0 0 2H6v4.592A6.668 6.668 0 0 1 7 .333" fill="currentColor"/>`,
width: 14,
height: 14,
},
whatsapp: {
body: `<path d="M7 .333A6.667 6.667 0 1 1 3.554 12.71l-2.02.594a.673.673 0 0 1-.836-.836l.593-2.021A6.667 6.667 0 0 1 7 .334M5.524 3.84a.46.46 0 0 0-.456-.05 1.86 1.86 0 0 0-1.072 1.258l-.004.02c-.026.101-.047.22-.042.35.016.455.194 1.743 1.543 3.091 1.347 1.348 2.634 1.527 3.09 1.543.13.005.249-.017.35-.042l.023-.006a1.85 1.85 0 0 0 1.254-1.067.47.47 0 0 0-.06-.469c-.355-.455-.832-.784-1.256-1.077l-.085-.058a.474.474 0 0 0-.649.105l-.4.61a.153.153 0 0 1-.203.05 5 5 0 0 1-.951-.703 4.4 4.4 0 0 1-.671-.915.15.15 0 0 1 .045-.194l.615-.457a.474.474 0 0 0 .081-.626l-.09-.133c-.28-.413-.607-.898-1.062-1.23" fill="currentColor"/>`,
width: 14,
height: 14,
},
instagram: {
body: `<path d="M8.667 0A3.333 3.333 0 0 1 12 3.333v5.334A3.333 3.333 0 0 1 8.667 12H3.333A3.333 3.333 0 0 1 0 8.667V3.333A3.333 3.333 0 0 1 3.333 0zM6 3.333a2.667 2.667 0 1 0 0 5.334 2.667 2.667 0 0 0 0-5.334m0 1.334a1.333 1.333 0 1 1 0 2.666 1.333 1.333 0 0 1 0-2.666m3-2.334a.667.667 0 1 0 0 1.334.667.667 0 0 0 0-1.334" fill="currentColor"/>`,
width: 12,
height: 12,
},
messenger: {
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M.333 7a6.667 6.667 0 1 1 3.221 5.709l-2.033.597a.667.667 0 0 1-.827-.827l.598-2.033A6.64 6.64 0 0 1 .333 7M5.53 5.53c.26-.26.682-.26.942 0L8 7.057 9.529 5.53a.667.667 0 1 1 .942.943l-2 2a.667.667 0 0 1-.942 0L6 6.943 4.471 8.472a.667.667 0 1 1-.942-.943z" fill="currentColor"/>`,
width: 14,
height: 14,
},
mail: {
body: `<g clip-path="url(#a)" fill="currentColor"><path d="M0 4.676q.058.03.116.063l.065.036c.105.06.204.128.304.196l.068.047.54.366.083.056q.421.284.847.562.598.393 1.19.793.673.454 1.352.9a57 57 0 0 1 1.1.738c.525.356 1.087.543 1.725.437.46-.096.845-.36 1.225-.621q.193-.134.387-.265l.081-.055q.418-.284.843-.56.597-.392 1.19-.793.669-.452 1.345-.896.38-.249.757-.501l.076-.05.358-.24.127-.086.059-.04c.132-.087.132-.087.162-.087l.007 2.49v.354l.006 1.92.001.545.002.703c.004.486-.045.832-.387 1.2-.262.242-.547.369-.907.369h-.409l-.403.001h-.434l-.85.001h-.692l-1.96.001H7.76l-1.715.001-2.75.002a262 262 0 0 1-1.664 0l-.142.001c-.422.003-.775-.061-1.098-.354l-.063-.07-.04-.04c-.17-.181-.292-.427-.291-.678V9.407l.001-1.933v-.06z"/><path d="M1.278 1.743h.263l.549-.001h.434l1.542-.002H6.24q.858 0 1.715-.002a1231 1231 0 0 1 3.592-.002h.964c.422-.003.775.061 1.098.354l.063.07.04.04c.26.272.3.613.296.97-.013.348-.198.612-.44.85a2 2 0 0 1-.303.221l-.07.046-.15.097-.407.265-.084.055q-.421.276-.837.56-.63.425-1.265.842-.565.371-1.124.75a123 123 0 0 1-1.22.817c-.46.305-.85.525-1.419.433a1.6 1.6 0 0 1-.46-.213l-.056-.036q-.33-.211-.651-.433A98 98 0 0 0 4.21 6.55q-.634-.417-1.262-.843-.531-.358-1.065-.71l-.654-.43-.062-.041-.47-.312C.451 4.047.235 3.873.108 3.6l-.046-.097a1.37 1.37 0 0 1-.005-.94c.16-.352.434-.613.79-.76a1.5 1.5 0 0 1 .43-.061"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h14v14H0z"/></clipPath></defs>`,
width: 14,
height: 14,
},
sms: {
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M.333 2a2 2 0 0 1 2-2h9.334a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-1.448a.67.67 0 0 0-.472.195L8.414 11.53a2 2 0 0 1-2.828 0l-1.333-1.334A.67.67 0 0 0 3.78 10H2.333a2 2 0 0 1-2-2zm3.334.667a.667.667 0 1 0 0 1.333h6.666a.667.667 0 1 0 0-1.333zm0 2.666a.667.667 0 1 0 0 1.334H7a.667.667 0 1 0 0-1.334z" fill="currentColor"/>`,
width: 14,
height: 12,
},
telegram: {
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M12.185.287A1 1 0 0 1 13.56 1.37l-1.513 9.171c-.146.884-1.117 1.392-1.928.951a39 39 0 0 1-2.594-1.53c-.453-.296-1.841-1.245-1.67-1.921C6 7.464 8.332 5.292 9.666 4c.524-.507.285-.8-.334-.333C7.8 4.826 5.336 6.587 4.521 7.083c-.719.438-1.094.512-1.542.438a10.2 10.2 0 0 1-2.194-.604c-.836-.346-.795-1.496 0-1.83z" fill="currentColor"/>`,
width: 14,
height: 12,
},
api: {
body: `<path d="M4.468 11.5q-1.14 0-1.776-.636-.624-.636-.624-1.632v-.996q0-.504-.12-.792a.7.7 0 0 0-.348-.408q-.24-.132-.624-.132V5.488q.384 0 .624-.12a.7.7 0 0 0 .348-.408q.12-.3.12-.804V3.16q0-.996.624-1.62.636-.636 1.776-.636h.756v1.548q-.756-.036-1.104.204-.336.24-.336.792v.828q0 .852-.42 1.332-.408.48-.972.552v.072q.564.06.972.552.42.48.42 1.332v.84q0 .552.336.816.348.252 1.104.228v1.5zm6.855 0h-.756V10q.756.024 1.092-.228.348-.264.348-.816v-.84q0-.852.408-1.332.42-.492.984-.552V6.16q-.552-.072-.972-.552t-.42-1.332v-.828q0-.552-.348-.792-.336-.24-1.092-.204V.904h.756q1.152 0 1.776.636.624.624.624 1.62v.996q0 .504.108.804.12.288.36.408t.624.12v1.416q-.384 0-.624.132a.76.76 0 0 0-.36.408q-.108.288-.108.792v.996q0 .996-.624 1.632t-1.776.636" fill="currentColor"/>`,
width: 15,
height: 12,
},
twilio: {
body: `<path d="M8 .5c4.14 0 7.5 3.36 7.5 7.5s-3.36 7.5-7.5 7.5S.5 12.14.5 8 3.86.5 8 .5m0 1.98C4.94 2.48 2.48 4.94 2.48 8S4.94 13.52 8 13.52s5.52-2.46 5.52-5.52S11.06 2.48 8 2.48M9.86 8.3a1.56 1.56 0 1 1 0 3.12 1.56 1.56 0 0 1 0-3.12m-3.72 0a1.56 1.56 0 1 1 0 3.12 1.56 1.56 0 0 1 0-3.12m3.72-3.72a1.56 1.56 0 1 1 0 3.12 1.56 1.56 0 0 1 0-3.12m-3.72 0a1.56 1.56 0 1 1 0 3.12 1.56 1.56 0 0 1 0-3.12" fill="currentColor"/>`,
width: 16,
height: 16,
},
gmail: {
body: `<path d="M3.182 12.253H.955A.954.954 0 0 1 0 11.298V3.185c0-1.18 1.346-1.854 2.29-1.146L7 5.571l4.71-3.532c.942-.708 2.29-.035 2.29 1.146v8.113a.954.954 0 0 1-.955.955h-2.227v-5.41L7 9.708 3.182 6.844z" fill="currentColor"/>`,
width: 14,
height: 14,
},
outlook: {
body: `<path d="M6.18.365a1 1 0 0 0-.267.028l-4.405.978A1.505 1.505 0 0 0 .333 2.836v8.328c0 .7.492 1.313 1.175 1.465l4.405.98v-.001c.718.16 1.42-.404 1.42-1.14V1.532C7.333.888 6.796.376 6.18.365m2.153 2.319v3.713l1.17.826 4.494-3.1c-.03-.8-.69-1.44-1.497-1.44zM3.833 4.5c1.028 0 1.834 1.172 1.834 2.667S4.86 9.834 3.833 9.834 2 8.662 2 7.167 2.805 4.5 3.833 4.5M14 5.337 9.783 8.244a.49.49 0 0 1-.573-.003l-.877-.621v4.064H12.5c.827 0 1.5-.674 1.5-1.5zM3.833 5.5C3.44 5.5 3 6.213 3 7.167s.44 1.667.833 1.667c.394 0 .834-.713.834-1.667S4.227 5.5 3.833 5.5" fill="currentColor"/>`,
width: 14,
height: 14,
},
/** Ends */
};