Merge branch 'fix/whatsapp-template-document-filename-extraction' of https://github.com/chatwoot/chatwoot into fix/whatsapp-template-document-filename-extraction

This commit is contained in:
Muhsin
2025-09-18 09:10:39 +05:30
854 changed files with 26708 additions and 4033 deletions
+9 -2
View File
@@ -19,6 +19,7 @@ import {
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
name: 'App',
@@ -38,12 +39,14 @@ export default {
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
const { uiSettings } = useUISettings();
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
uiSettings,
};
},
data() {
@@ -88,7 +91,10 @@ export default {
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
// If user locale is set, use it; otherwise use account locale
this.setLocale(
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
);
},
unmounted() {
if (this.reconnectService) {
@@ -114,7 +120,8 @@ export default {
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
@@ -0,0 +1,43 @@
/* global axios */
import ApiClient from './ApiClient';
class AgentCapacityPolicies extends ApiClient {
constructor() {
super('agent_capacity_policies', { accountScoped: true });
}
getUsers(policyId) {
return axios.get(`${this.url}/${policyId}/users`);
}
addUser(policyId, userData) {
return axios.post(`${this.url}/${policyId}/users`, {
user_id: userData.id,
capacity: userData.capacity,
});
}
removeUser(policyId, userId) {
return axios.delete(`${this.url}/${policyId}/users/${userId}`);
}
createInboxLimit(policyId, limitData) {
return axios.post(`${this.url}/${policyId}/inbox_limits`, {
inbox_id: limitData.inboxId,
conversation_limit: limitData.conversationLimit,
});
}
updateInboxLimit(policyId, limitId, limitData) {
return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, {
conversation_limit: limitData.conversationLimit,
});
}
deleteInboxLimit(policyId, limitId) {
return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`);
}
}
export default new AgentCapacityPolicies();
@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from './ApiClient';
class AssignmentPolicies extends ApiClient {
constructor() {
super('assignment_policies', { accountScoped: true });
}
getInboxes(policyId) {
return axios.get(`${this.url}/${policyId}/inboxes`);
}
setInboxPolicy(inboxId, policyId) {
return axios.post(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`,
{
assignment_policy_id: policyId,
}
);
}
getInboxPolicy(inboxId) {
return axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
);
}
removeInboxPolicy(inboxId) {
return axios.delete(
`/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`
);
}
}
export default new AssignmentPolicies();
@@ -0,0 +1,98 @@
import agentCapacityPolicies from '../agentCapacityPolicies';
import ApiClient from '../ApiClient';
describe('#AgentCapacityPoliciesAPI', () => {
it('creates correct instance', () => {
expect(agentCapacityPolicies).toBeInstanceOf(ApiClient);
expect(agentCapacityPolicies).toHaveProperty('get');
expect(agentCapacityPolicies).toHaveProperty('show');
expect(agentCapacityPolicies).toHaveProperty('create');
expect(agentCapacityPolicies).toHaveProperty('update');
expect(agentCapacityPolicies).toHaveProperty('delete');
expect(agentCapacityPolicies).toHaveProperty('getUsers');
expect(agentCapacityPolicies).toHaveProperty('addUser');
expect(agentCapacityPolicies).toHaveProperty('removeUser');
expect(agentCapacityPolicies).toHaveProperty('createInboxLimit');
expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit');
expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
post: vi.fn(() => Promise.resolve()),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
// Mock accountIdFromRoute
Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', {
get: () => '1',
configurable: true,
});
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getUsers', () => {
agentCapacityPolicies.getUsers(123);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users'
);
});
it('#addUser', () => {
const userData = { id: 456, capacity: 20 };
agentCapacityPolicies.addUser(123, userData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users',
{
user_id: 456,
capacity: 20,
}
);
});
it('#removeUser', () => {
agentCapacityPolicies.removeUser(123, 456);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/users/456'
);
});
it('#createInboxLimit', () => {
const limitData = { inboxId: 1, conversationLimit: 10 };
agentCapacityPolicies.createInboxLimit(123, limitData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits',
{
inbox_id: 1,
conversation_limit: 10,
}
);
});
it('#updateInboxLimit', () => {
const limitData = { conversationLimit: 15 };
agentCapacityPolicies.updateInboxLimit(123, 789, limitData);
expect(axiosMock.put).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789',
{
conversation_limit: 15,
}
);
});
it('#deleteInboxLimit', () => {
agentCapacityPolicies.deleteInboxLimit(123, 789);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789'
);
});
});
});
@@ -0,0 +1,70 @@
import assignmentPolicies from '../assignmentPolicies';
import ApiClient from '../ApiClient';
describe('#AssignmentPoliciesAPI', () => {
it('creates correct instance', () => {
expect(assignmentPolicies).toBeInstanceOf(ApiClient);
expect(assignmentPolicies).toHaveProperty('get');
expect(assignmentPolicies).toHaveProperty('show');
expect(assignmentPolicies).toHaveProperty('create');
expect(assignmentPolicies).toHaveProperty('update');
expect(assignmentPolicies).toHaveProperty('delete');
expect(assignmentPolicies).toHaveProperty('getInboxes');
expect(assignmentPolicies).toHaveProperty('setInboxPolicy');
expect(assignmentPolicies).toHaveProperty('getInboxPolicy');
expect(assignmentPolicies).toHaveProperty('removeInboxPolicy');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
post: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
// Mock accountIdFromRoute
Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', {
get: () => '1',
configurable: true,
});
});
afterEach(() => {
window.axios = originalAxios;
});
it('#getInboxes', () => {
assignmentPolicies.getInboxes(123);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/accounts/1/assignment_policies/123/inboxes'
);
});
it('#setInboxPolicy', () => {
assignmentPolicies.setInboxPolicy(456, 123);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/inboxes/456/assignment_policy',
{
assignment_policy_id: 123,
}
);
});
it('#getInboxPolicy', () => {
assignmentPolicies.getInboxPolicy(456);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/accounts/1/inboxes/456/assignment_policy'
);
});
it('#removeInboxPolicy', () => {
assignmentPolicies.removeInboxPolicy(456);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/accounts/1/inboxes/456/assignment_policy'
);
});
});
});
@@ -0,0 +1,116 @@
<script setup>
import AgentCapacityPolicyCard from './AgentCapacityPolicyCard.vue';
const mockUsers = [
{
id: 1,
name: 'John Smith',
email: 'john.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Sarah Johnson',
email: 'sarah.johnson@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
{
id: 3,
name: 'Mike Chen',
email: 'mike.chen@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
},
{
id: 4,
name: 'Emily Davis',
email: 'emily.davis@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
},
{
id: 5,
name: 'Alex Rodriguez',
email: 'alex.rodriguez@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
},
];
const withCount = policy => ({
...policy,
assignedAgentCount: policy.users.length,
});
const policyA = withCount({
id: 1,
name: 'High Volume Support',
description:
'Capacity-based policy for handling high conversation volumes with experienced agents',
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
isFetchingUsers: false,
});
const policyB = withCount({
id: 2,
name: 'Specialized Team',
description: 'Custom capacity limits for specialized support team members',
users: [mockUsers[3], mockUsers[4]],
isFetchingUsers: false,
});
const emptyPolicy = withCount({
id: 3,
name: 'New Policy',
description: 'Recently created policy with no assigned agents yet',
users: [],
isFetchingUsers: false,
});
const loadingPolicy = withCount({
id: 4,
name: 'Loading Policy',
description: 'Policy currently loading agent information',
users: [],
isFetchingUsers: true,
});
const onEdit = id => console.log('Edit policy:', id);
const onDelete = id => console.log('Delete policy:', id);
const onFetchUsers = id => console.log('Fetch users for policy:', id);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AgentCapacityPolicyCard"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Multiple Cards (Various States)">
<div class="p-4 bg-n-background">
<div class="grid grid-cols-1 gap-4">
<AgentCapacityPolicyCard
v-bind="policyA"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="policyB"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="emptyPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
<AgentCapacityPolicyCard
v-bind="loadingPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-users="onFetchUsers"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import CardPopover from '../components/CardPopover.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
description: { type: String, default: '' },
assignedAgentCount: { type: Number, default: 0 },
users: { type: Array, default: () => [] },
isFetchingUsers: { type: Boolean, default: false },
});
const emit = defineEmits(['edit', 'delete', 'fetchUsers']);
const { t } = useI18n();
const users = computed(() => {
return props.users.map(user => {
return {
name: user.name,
key: user.id,
email: user.email,
avatarUrl: user.avatarUrl,
};
});
});
const handleEdit = () => {
emit('edit', props.id);
};
const handleDelete = () => {
emit('delete', props.id);
};
const handleFetchUsers = () => {
if (props.users?.length > 0) return;
emit('fetchUsers', props.id);
};
</script>
<template>
<CardLayout class="[&>div]:px-5">
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-users-round"
:count="assignedAgentCount"
:items="users"
:is-fetching="isFetchingUsers"
@fetch="handleFetchUsers"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.CARD.EDIT')
"
sm
slate
link
class="px-2"
@click="handleEdit"
/>
<div class="w-px h-2.5 bg-n-slate-5" />
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
</CardLayout>
</template>
@@ -0,0 +1,63 @@
<script setup>
import AssignmentCard from './AssignmentCard.vue';
const agentAssignments = [
{
id: 1,
title: 'Assignment policy',
description: 'Manage how conversations get assigned in inboxes.',
features: [
{
icon: 'i-lucide-circle-fading-arrow-up',
label: 'Assign by conversations evenly or by available capacity',
},
{
icon: 'i-lucide-scale',
label: 'Add fair distribution rules to avoid overloading any agent',
},
{
icon: 'i-lucide-inbox',
label: 'Add inboxes to a policy - one policy per inbox',
},
],
},
{
id: 2,
title: 'Agent capacity policy',
description: 'Manage workload for agents.',
features: [
{
icon: 'i-lucide-glass-water',
label: 'Define maximum conversations per inbox',
},
{
icon: 'i-lucide-circle-minus',
label: 'Create exceptions based on labels and time',
},
{
icon: 'i-lucide-users-round',
label: 'Add agents to a policy - one policy per agent',
},
],
},
];
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AssignmentCard"
:layout="{ type: 'grid', width: '1000px' }"
>
<Variant title="Assignment Card">
<div class="px-4 py-4 bg-n-background flex gap-6 justify-between">
<AssignmentCard
v-for="(item, index) in agentAssignments"
:key="index"
:title="item.title"
:description="item.description"
:features="item.features"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,49 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
features: { type: Array, default: () => [] },
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<Button
xs
slate
ghost
icon="i-lucide-chevron-right"
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-sm"
>
<Icon
:icon="feature.icon"
class="text-n-slate-11 size-4 flex-shrink-0"
/>
{{ feature.label }}
</li>
</ul>
</CardLayout>
</template>
@@ -0,0 +1,104 @@
<script setup>
import AssignmentPolicyCard from './AssignmentPolicyCard.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
channel_type: 'Channel::WebWidget',
inbox_type: 'Website',
},
{
id: 2,
name: 'Email Support',
channel_type: 'Channel::Email',
inbox_type: 'Email',
},
{
id: 3,
name: 'WhatsApp Business',
channel_type: 'Channel::Whatsapp',
inbox_type: 'WhatsApp',
},
{
id: 4,
name: 'Facebook Messenger',
channel_type: 'Channel::FacebookPage',
inbox_type: 'Messenger',
},
];
const withCount = policy => ({
...policy,
assignedInboxCount: policy.inboxes.length,
});
const policyA = withCount({
id: 1,
name: 'Website & Email',
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
const policyB = withCount({
id: 2,
name: 'WhatsApp & Messenger',
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
const emptyPolicy = withCount({
id: 3,
name: 'No Inboxes Yet',
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
const onEdit = id => console.log('Edit policy:', id);
const onDelete = id => console.log('Delete policy:', id);
const onFetch = () => console.log('Fetch inboxes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AssignmentPolicyCard"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Three Cards (Two with inboxes, One empty)">
<div class="p-4 bg-n-background">
<div class="grid grid-cols-1 gap-4">
<AssignmentPolicyCard
v-bind="policyA"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
<AssignmentPolicyCard
v-bind="policyB"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
<AssignmentPolicyCard
v-bind="emptyPolicy"
@edit="onEdit"
@delete="onDelete"
@fetch-inboxes="onFetch"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,133 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { formatToTitleCase } from 'dashboard/helper/commons';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import CardPopover from '../components/CardPopover.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
description: { type: String, default: '' },
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
const emit = defineEmits(['edit', 'delete', 'fetchInboxes']);
const { t } = useI18n();
const inboxes = computed(() => {
return props.inboxes.map(inbox => {
return {
name: inbox.name,
id: inbox.id,
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
};
});
});
const order = computed(() => {
return formatToTitleCase(props.assignmentOrder);
});
const priority = computed(() => {
return formatToTitleCase(props.conversationPriority);
});
const handleEdit = () => {
emit('edit', props.id);
};
const handleDelete = () => {
emit('delete', props.id);
};
const handleFetchInboxes = () => {
if (props.inboxes?.length > 0) return;
emit('fetchInboxes', props.id);
};
</script>
<template>
<CardLayout class="[&>div]:px-5">
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Button
:label="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.EDIT')
"
sm
slate
link
class="px-2"
@click="handleEdit"
/>
<div v-if="order" class="w-px h-2.5 bg-n-slate-5" />
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}
<span class="text-n-slate-12">{{ priority }}</span>
</span>
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,119 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
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';
const props = defineProps({
label: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add']);
const [showPopover, togglePopover] = useToggle();
const searchValue = ref('');
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
return picoSearch(props.items, query, ['name']);
});
const handleAdd = inbox => {
emit('add', inbox);
togglePopover(false);
};
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<Button
slate
type="button"
icon="i-lucide-plus"
sm
:label="label"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
v-model="searchValue"
:placeholder="searchPlaceholder"
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-start gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
@click="handleAdd(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<div class="flex flex-col items-start gap-2 min-w-0">
<div class="flex items-center gap-1 min-w-0">
<span
:title="item.name"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
</span>
<span
v-if="item.id"
class="text-xs text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,126 @@
<script setup>
import { computed, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
nameLabel: {
type: String,
default: '',
},
namePlaceholder: {
type: String,
default: '',
},
descriptionLabel: {
type: String,
default: '',
},
descriptionPlaceholder: {
type: String,
default: '',
},
statusLabel: {
type: String,
default: '',
},
statusPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['validationChange']);
const policyName = defineModel('policyName', {
type: String,
default: '',
});
const description = defineModel('description', {
type: String,
default: '',
});
const enabled = defineModel('enabled', {
type: Boolean,
default: true,
});
const validationRules = {
policyName: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, { policyName, description });
const isValid = computed(() => !v$.value.$invalid);
watch(
isValid,
() => {
emit('validationChange', {
isValid: isValid.value,
});
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 pb-4">
<!-- Policy Name Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="nameLabel"
name="policyName"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="policyName"
type="text"
:placeholder="namePlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Description Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="descriptionLabel"
name="description"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="description"
type="text"
:placeholder="descriptionPlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Status Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex items-center gap-2">
<Switch v-model="enabled" />
<span class="text-sm text-n-slate-11">
{{ statusPlaceholder }}
</span>
</div>
</WithLabel>
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
count: {
type: Number,
default: 0,
},
title: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['fetch']);
const [showPopover, togglePopover] = useToggle();
const handleButtonClick = () => {
emit('fetch');
togglePopover(!showPopover.value);
};
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<button
v-if="count"
class="h-6 px-2 rounded-md bg-n-alpha-2 gap-1.5 flex items-center"
@click="handleButtonClick()"
>
<Icon :icon="icon" class="size-3.5 text-n-slate-12" />
<span class="text-n-slate-12 text-sm">
{{ count }}
</span>
</button>
<div
v-if="showPopover"
class="top-full mt-1 ltr:left-0 rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak p-3 rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto"
>
<div class="flex items-center gap-2.5 pb-2">
<Icon :icon="icon" class="size-3.5" />
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-4 w-full">
<div
v-for="item in items"
:key="item.id"
class="flex items-center justify-between gap-2 min-w-0 w-full"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<div class="flex items-center gap-1 min-w-0 flex-1">
<span
:title="item.name"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
</span>
<span
v-if="item.id"
class="text-sm text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
</div>
<span v-if="item.email" class="text-sm text-n-slate-11 flex-shrink-0">
{{ item.email }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<span
v-else-if="items.length === 0 && emptyStateMessage"
class="flex items-center justify-center pt-4 pb-8 w-full text-sm text-n-slate-11"
>
{{ emptyStateMessage }}
</span>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="item in items"
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
<div class="col-span-1 justify-end flex items-center">
<Button
icon="i-lucide-trash"
slate
ghost
sm
type="button"
@click="handleDelete(item.id)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const fairDistributionLimit = defineModel('fairDistributionLimit', {
type: Number,
default: 100,
set(value) {
return Number(value) || 0;
},
});
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
set(value) {
return Number(value) || 0;
},
});
const windowUnit = ref(DURATION_UNITS.MINUTES);
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
if (m % 60 === 0) return DURATION_UNITS.HOURS;
return DURATION_UNITS.MINUTES;
};
onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
<template>
<div
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
>
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
)
}}
</label>
<div class="flex-1">
<Input
v-model="fairDistributionLimit"
type="number"
placeholder="100"
max="100000"
class="w-full"
/>
</div>
</div>
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
)
}}
</label>
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup>
import AddDataDropdown from '../AddDataDropdown.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
email: 'messenger@company.com',
icon: 'i-lucide-facebook',
},
{
id: 5,
name: 'Twitter DM',
email: 'twitter@company.com',
icon: 'i-lucide-twitter',
},
];
const handleAdd = item => {
console.log('Add item:', item);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
search-placeholder="Search inboxes..."
:items="mockInboxes"
@add="handleAdd"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,33 @@
<script setup>
import { ref } from 'vue';
import BaseInfo from '../BaseInfo.vue';
const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/BaseInfo"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,89 @@
<script setup>
import CardPopover from '../CardPopover.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
icon: 'i-lucide-facebook',
},
];
const mockUsers = [
{
id: 1,
name: 'John Smith',
email: 'john.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Sarah Johnson',
email: 'sarah.johnson@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
{
id: 3,
name: 'Mike Chen',
email: 'mike.chen@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=3',
},
{
id: 4,
name: 'Emily Davis',
email: 'emily.davis@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=4',
},
{
id: 5,
name: 'Alex Rodriguez',
email: 'alex.rodriguez@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=5',
},
];
</script>
<template>
<Story
title="Components/AgentManagementPolicy/CardPopover"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
<CardPopover
:count="3"
title="Added Inboxes"
icon="i-lucide-inbox"
:items="mockItems.slice(0, 3)"
@fetch="() => console.log('Fetch triggered')"
/>
</div>
</Variant>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-96 items-start">
<CardPopover
:count="3"
title="Added Agents"
icon="i-lucide-users-round"
:items="mockUsers.slice(0, 3)"
@fetch="() => console.log('Fetch triggered')"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,62 @@
<script setup>
import DataTable from '../DataTable.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
];
const handleDelete = itemId => {
console.log('Delete item:', itemId);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
<DataTable
:items="mockItems"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<DataTable :items="[]" is-fetching @delete="handleDelete" />
</div>
</Variant>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<DataTable
:items="[]"
:is-fetching="false"
empty-state-message="No items found"
@delete="handleDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,25 @@
<script setup>
import { ref } from 'vue';
import FairDistribution from '../FairDistribution.vue';
const fairDistributionLimit = ref(100);
const fairDistributionWindow = ref(3600);
const windowUnit = ref('minutes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/FairDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<FairDistribution
v-model:fair-distribution-limit="fairDistributionLimit"
v-model:fair-distribution-window="fairDistributionWindow"
v-model:window-unit="windowUnit"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin');
const handleSelect = value => {
selectedOption.value = value;
console.log('Selected:', value);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/RadioCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background space-y-4">
<RadioCard
id="round_robin"
label="Round Robin"
description="Distributes conversations evenly among all available agents in a rotating manner"
:is-active="selectedOption === 'round_robin'"
@select="handleSelect"
/>
<RadioCard
id="balanced"
label="Balanced Assignment"
description="Assigns conversations based on agent workload to maintain balance"
:is-active="selectedOption === 'balanced'"
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Active State">
<div class="p-8 bg-n-background">
<RadioCard
id="active_option"
label="Active Option"
description="This option is currently selected and active"
is-active
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Inactive State">
<div class="p-8 bg-n-background">
<RadioCard
id="inactive_option"
label="Inactive Option"
description="This option is not selected and can be clicked to activate"
is-active
@select="handleSelect"
/>
</div>
</Variant>
</Story>
</template>
@@ -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>
@@ -1,177 +0,0 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
:title="key"
>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -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>
@@ -1,25 +1,49 @@
<script setup>
import { ref } from 'vue';
import { ref, nextTick, onMounted } from 'vue';
const emit = defineEmits(['send']);
const message = ref('');
const textareaRef = ref(null);
const adjustHeight = () => {
if (!textareaRef.value) return;
// Reset height to auto to get the correct scrollHeight
textareaRef.value.style.height = 'auto';
// Set the height to the scrollHeight
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
};
const sendMessage = () => {
if (message.value.trim()) {
emit('send', message.value);
message.value = '';
// Reset textarea height after sending
nextTick(() => {
adjustHeight();
});
}
};
const handleInput = () => {
nextTick(adjustHeight);
};
onMounted(() => {
nextTick(adjustHeight);
});
</script>
<template>
<form class="relative" @submit.prevent="sendMessage">
<input
<textarea
ref="textareaRef"
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -15,7 +15,7 @@ defineProps({
>
{{ title }}
</div>
<ul class="gap-2 grid reset-base list-none px-2">
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
<slot />
</ul>
</div>
@@ -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');
});
});
@@ -78,6 +78,8 @@ watch(unit, () => {
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.DAYS">
{{ t('DURATION_INPUT.DAYS') }}
</option>
</select>
</template>
@@ -15,6 +15,7 @@ const props = defineProps({
validator: value => ['info', 'error', 'success'].includes(value),
},
min: { type: String, default: '' },
max: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
@@ -108,6 +109,11 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
:max="
['date', 'datetime-local', 'time', 'number'].includes(type)
? max
: undefined
"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue';
import LocationBubble from './bubbles/Location.vue';
import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -280,6 +281,10 @@ const componentToRender = computed(() => {
return FormBubble;
}
if (props.contentType === CONTENT_TYPES.VOICE_CALL) {
return VoiceCallBubble;
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
@@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const { t } = useI18n();
@@ -64,6 +68,13 @@ const scrollToMessage = () => {
});
};
const shouldShowMeta = computed(
() =>
!props.hideMeta &&
!shouldGroupWithNext.value &&
variant.value !== MESSAGE_VARIANTS.ACTIVITY
);
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
@@ -93,16 +104,16 @@ const replyToPreview = computed(() => {
>
<div
v-if="inReplyTo"
class="bg-n-alpha-black1 rounded-lg p-2 -mx-1 mb-2 cursor-pointer"
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<span class="line-clamp-2 break-all">
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
</div>
<slot />
<MessageMeta
v-if="!shouldGroupWithNext && variant !== MESSAGE_VARIANTS.ACTIVITY"
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
@@ -1,6 +1,7 @@
<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
@@ -37,12 +38,12 @@ const { hasTranslations, translationContent } =
const originalEmailText = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
return sanitizeTextForRender(text);
});
const originalEmailHtml = computed(
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
contentAttributes?.value?.email?.htmlContent?.full ||
originalEmailText.value
);
@@ -0,0 +1,43 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { useMessageContext } from '../provider.js';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
const { contentAttributes } = useMessageContext();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status);
const direction = computed(() => data.value?.call_direction);
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
useVoiceCallStatus(status, direction);
const containerRingClass = computed(() => {
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
});
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
:class="containerRingClass"
>
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
:class="bubbleIconBg"
>
<span class="text-xl" :class="bubbleIconName" />
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
</div>
</div>
</div>
</BaseBubble>
</template>
@@ -64,6 +64,7 @@ export const CONTENT_TYPES = {
INPUT_CSAT: 'input_csat',
INTEGRATIONS: 'integrations',
STICKER: 'sticker',
VOICE_CALL: 'voice_call',
};
export const MEDIA_TYPES = [
@@ -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',
},
},
{
@@ -422,6 +422,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
@@ -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,4 +1,13 @@
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
* 1. Displays the template text with variable placeholders.
* 2. Generates input fields for each variable in the template.
* 3. Validates that all variables are filled before sending.
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
@@ -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,5 +1,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
@@ -100,20 +101,24 @@ const handleReset = () => {
};
const sendMessage = async message => {
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
try {
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
} catch (error) {
useAlert(error.message);
}
};
@@ -1,97 +1,81 @@
<script>
export default {
props: {
items: {
type: Array,
default: () => [],
},
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
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;
},
},
};
});
const route = useRoute();
const activeIndex = computed(() => {
const index = props.items.findIndex(i => i.route === route.name);
return index === -1 ? 0 : index;
});
const steps = computed(() =>
props.items.map((item, index) => {
const isActive = index === activeIndex.value;
const isOver = index < activeIndex.value;
return {
...item,
index,
isActive,
isOver,
};
})
);
</script>
<template>
<transition-group
name="wizard-items"
tag="div"
class="wizard-box"
:class="classObject"
>
<transition-group tag="div">
<div
v-for="item in items"
:key="item.route"
class="item"
:class="{ active: isActive(item), over: isOver(item) }"
v-for="step in steps"
:key="step.route"
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 after:bg-n-slate-3 before:bg-n-slate-3"
>
<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"
<!-- Circle -->
<div
class="rounded-2xl flex-shrink-0 size-8 border-2 border-n-slate-3 flex items-center justify-center left-2 leading-4 z-10 top-5 transition-all duration-300 ease-in-out"
:class="{
'bg-n-slate-3': step.isActive || step.isOver,
'bg-n-background': !step.isActive && !step.isOver,
}"
>
<span
v-if="!step.isOver"
:key="'num-' + step.index"
class="text-xs font-bold transition-colors duration-300"
:class="step.isActive ? '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" />
{{ step.index + 1 }}
</span>
<Icon
v-else
:key="'check-' + step.index"
icon="i-lucide-check"
class="text-n-slate-11 size-4"
/>
</div>
<!-- Content -->
<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="step.isActive ? 'text-n-blue-11' : 'text-n-slate-12'"
>
{{ step.title }}
</h3>
</div>
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
{{ step.body }}
</p>
</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>
</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 }}
@@ -8,6 +8,10 @@ const props = defineProps({
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
disabled: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -20,9 +24,12 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
* Computes the width of the sliding background chip in pixels
@@ -53,6 +60,10 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
}"
@click="$emit('toggleMode')"
>
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
@@ -62,7 +73,10 @@ const translateValue = computed(() => {
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</div>
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] transition-all duration-300 ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
}"
:style="{
'--chip-width': width,
'--translate-x': translateValue,
@@ -6,15 +6,11 @@ import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
} from 'shared/constants/messages';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -23,9 +19,9 @@ export default {
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
mode: {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
isNote: {
type: Boolean,
default: false,
},
onSend: {
type: Function,
@@ -98,6 +94,10 @@ export default {
type: Boolean,
default: false,
},
enableContentTemplates: {
type: Boolean,
default: false,
},
conversationId: {
type: Number,
required: true,
@@ -124,6 +124,7 @@ export default {
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -155,15 +156,17 @@ export default {
uploadRef,
};
},
data() {
return {
ALLOWED_FILE_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
wrapClass() {
return {
'is-note-mode': this.isNote,
@@ -196,17 +199,21 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
if (this.isAnInstagramChannel || this.isInstagramDM) {
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
// Use default file types for private notes
if (this.isOnPrivateNote) {
return this.ALLOWED_FILE_TYPES;
}
return ALLOWED_FILE_TYPES;
let channelType = this.channelType || this.inbox?.channel_type;
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
return getAllowedFileTypesByChannel({
channelType,
medium: this.inbox?.medium,
});
},
enableDragAndDrop() {
return !this.newConversationModalActive;
@@ -341,6 +348,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"
@@ -355,7 +371,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">
@@ -15,6 +15,10 @@ export default {
type: String,
default: REPLY_EDITOR_MODES.REPLY,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -30,6 +34,7 @@ export default {
emit('setReplyMode', mode);
};
const handleReplyClick = () => {
if (props.isReplyRestricted) return;
setReplyMode(REPLY_EDITOR_MODES.REPLY);
};
const handleNoteClick = () => {
@@ -88,6 +93,7 @@ export default {
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
@@ -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>
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
@@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const callStatus = computed(
() => props.chat.additional_attributes?.call_status
);
const callDirection = computed(
() => props.chat.additional_attributes?.call_direction
);
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
useVoiceCallStatus(callStatus, callDirection);
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
@@ -250,7 +261,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
@@ -307,14 +317,30 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
</h4>
<div
v-if="callStatus"
key="voice-status-row"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<span
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
:class="[voiceIconColor]"
/>
<span class="mx-1">
{{ $t(voiceLabelKey) }}
</span>
</div>
<MessagePreview
v-if="lastMessageInChat"
v-else-if="lastMessageInChat"
key="message-preview"
:message="lastMessageInChat"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
key="no-messages"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
@@ -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,44 +158,21 @@ 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;
}
return true;
},
isReplyRestricted() {
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -659,28 +639,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 +737,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
@@ -793,6 +763,10 @@ export default {
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
if (this.attachedFiles.length > 0) this.attachedFiles = [];
const { can_reply: canReply } = this.currentChat;
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
@@ -1095,19 +1069,11 @@ 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"
:is-reply-restricted="isReplyRestricted"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@@ -1213,11 +1179,12 @@ 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"
:is-send-disabled="isReplyButtonDisabled"
:mode="replyType"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
@@ -1235,6 +1202,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"
@@ -1247,6 +1215,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')"
@@ -1260,10 +1236,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>
@@ -1,10 +1,10 @@
<script>
import TemplatesPicker from './TemplatesPicker.vue';
import TemplateParser from './TemplateParser.vue';
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
export default {
components: {
TemplatesPicker,
TemplateParser,
WhatsAppTemplateReply,
},
props: {
show: {
@@ -68,7 +68,7 @@ export default {
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
<WhatsAppTemplateReply
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@@ -1,13 +1,4 @@
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
* 1. Displays the template text with variable placeholders.
* 2. Generates input fields for each variable in the template.
* 3. Validates that all variables are filled before sending.
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
@@ -13,6 +13,7 @@ vi.mock('dashboard/composables', () => ({
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper');
vi.mock('@chatwoot/utils');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
@@ -22,6 +23,11 @@ describe('useFileUpload', () => {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
const inbox = {
channel_type: 'Channel::WhatsApp',
medium: 'whatsapp',
};
beforeEach(() => {
vi.clearAllMocks();
@@ -37,11 +43,12 @@ describe('useFileUpload', () => {
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
});
it('should handle direct file upload when enabled', () => {
it('handles direct file upload when direct uploads enabled', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -52,6 +59,16 @@ describe('useFileUpload', () => {
onFileUpload(mockFile);
// size rules called with inbox + mime
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
// size check called with max from helper
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
@@ -63,7 +80,7 @@ describe('useFileUpload', () => {
});
});
it('should handle indirect file upload when direct upload is disabled', () => {
it('handles indirect file upload when direct upload disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
@@ -75,22 +92,24 @@ describe('useFileUpload', () => {
});
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('should show alert when file size exceeds limit', () => {
it('shows alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -100,28 +119,37 @@ describe('useFileUpload', () => {
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should use different max file size for Twilio SMS channel', () => {
it('uses per-mime limits from helper', () => {
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
mime.startsWith('image/') ? 10 : 50
);
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: true,
inbox,
attachFile: mockAttachFile,
});
DirectUpload.mockImplementation(() => ({
create: cb => cb(null, { signed_id: 'blob' }),
}));
onFileUpload(mockFile);
expect(checkFileSizeLimit).toHaveBeenCalledWith(
mockFile,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
);
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
});
it('should handle direct upload errors', () => {
it('handles direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
@@ -131,15 +159,16 @@ describe('useFileUpload', () => {
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should do nothing when file is null', () => {
it('does nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
inbox,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
@@ -43,18 +43,22 @@ describe('useFontSize', () => {
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(5);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
expect(fontSizeOptions.value).toHaveLength(5);
expect(fontSizeOptions.value[0]).toHaveProperty('value');
expect(fontSizeOptions.value[0]).toHaveProperty('label');
// Check specific options
expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({
expect(
fontSizeOptions.value.find(option => option.value === '16px')
).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({
expect(
fontSizeOptions.value.find(option => option.value === '14px')
).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
@@ -143,12 +147,12 @@ describe('useFontSize', () => {
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
expect(fontSizeOptions.find(option => option.value === '14px').label).toBe(
'Smaller'
);
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
expect(
fontSizeOptions.value.find(option => option.value === '14px').label
).toBe('Smaller');
expect(
fontSizeOptions.value.find(option => option.value === '16px').label
).toBe('Default');
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
@@ -0,0 +1,277 @@
import { describe, it, expect, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useInbox } from '../useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useTransformKeys');
// Mock the dependencies
const mockStore = createStore({
modules: {
conversations: {
namespaced: false,
getters: {
getSelectedChat: () => ({ inbox_id: 1 }),
},
},
inboxes: {
namespaced: true,
getters: {
getInboxById: () => id => {
const inboxes = {
1: {
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
},
2: { id: 2, channel_type: INBOX_TYPES.FB },
3: { id: 3, channel_type: INBOX_TYPES.TWILIO, medium: 'sms' },
4: { id: 4, channel_type: INBOX_TYPES.TWILIO, medium: 'whatsapp' },
5: {
id: 5,
channel_type: INBOX_TYPES.EMAIL,
provider: 'microsoft',
},
6: { id: 6, channel_type: INBOX_TYPES.EMAIL, provider: 'google' },
7: {
id: 7,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'default',
},
8: { id: 8, channel_type: INBOX_TYPES.TELEGRAM },
9: { id: 9, channel_type: INBOX_TYPES.LINE },
10: { id: 10, channel_type: INBOX_TYPES.WEB },
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
};
return inboxes[id] || null;
},
},
},
},
});
// Mock useMapGetter to return mock store getters
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(getter => {
if (getter === 'getSelectedChat') {
return { value: { inbox_id: 1 } };
}
if (getter === 'inboxes/getInboxById') {
return { value: mockStore.getters['inboxes/getInboxById'] };
}
return { value: null };
}),
}));
// Mock useCamelCase to return the data as-is for testing
vi.mock('dashboard/composables/useTransformKeys', () => ({
useCamelCase: vi.fn(data => ({
...data,
channelType: data?.channel_type,
})),
}));
describe('useInbox', () => {
const createTestComponent = inboxId =>
defineComponent({
setup() {
return useInbox(inboxId);
},
render() {
return h('div');
},
});
describe('with current chat context (no inboxId provided)', () => {
it('identifies WhatsApp Cloud channel correctly', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
it('returns correct inbox object', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox).toEqual({
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
channelType: INBOX_TYPES.WHATSAPP,
});
});
});
describe('with explicit inboxId provided', () => {
it('identifies Facebook inbox correctly', () => {
const wrapper = mount(createTestComponent(2), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAFacebookInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio SMS channel correctly', () => {
const wrapper = mount(createTestComponent(3), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isASmsInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(4), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies Microsoft email inbox correctly', () => {
const wrapper = mount(createTestComponent(5), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(false);
});
it('identifies Google email inbox correctly', () => {
const wrapper = mount(createTestComponent(6), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(false);
});
it('identifies 360Dialog WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(7), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies all other channel types correctly', () => {
// Test Telegram
let wrapper = mount(createTestComponent(8), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATelegramChannel).toBe(true);
// Test Line
wrapper = mount(createTestComponent(9), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isALineChannel).toBe(true);
// Test Web Widget
wrapper = mount(createTestComponent(10), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
// Test API
wrapper = mount(createTestComponent(11), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAPIInbox).toBe(true);
// Test SMS
wrapper = mount(createTestComponent(12), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isASmsInbox).toBe(true);
// Test Instagram
wrapper = mount(createTestComponent(13), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
});
});
describe('edge cases', () => {
it('handles non-existent inbox ID gracefully', () => {
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
// useCamelCase still processes null data, so we get an object with channelType: undefined
expect(wrapper.vm.inbox).toEqual({ channelType: undefined });
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
});
it('handles inbox with no data correctly', () => {
// The mock will return null for non-existent IDs, but useCamelCase processes it
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox.channelType).toBeUndefined();
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
expect(wrapper.vm.isATelegramChannel).toBe(false);
});
});
describe('return object completeness', () => {
it('returns all expected properties', () => {
const wrapper = mount(createTestComponent(1), {
global: { plugins: [mockStore] },
});
const expectedProperties = [
'inbox',
'isAFacebookInbox',
'isALineChannel',
'isAPIInbox',
'isASmsInbox',
'isATelegramChannel',
'isATwilioChannel',
'isAWebWidgetInbox',
'isAWhatsAppChannel',
'isAMicrosoftInbox',
'isAGoogleInbox',
'isATwilioWhatsAppChannel',
'isAWhatsAppCloudChannel',
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
expect(wrapper.vm).toHaveProperty(prop);
});
});
});
});
@@ -1,22 +1,19 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import { MAXIMUM_FILE_UPLOAD_SIZE } from 'shared/constants/messages';
/**
* Composable for handling file uploads in conversations
* @param {Object} options - Configuration options
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
* @param {Function} options.attachFile - Callback function to handle file attachment
* @returns {Object} File upload methods and utilities
* @param {Object} options
* @param {Object} options.inbox - Current inbox object (has channel_type, medium, etc.)
* @param {Function} options.attachFile - Callback to handle file attachment
* @param {boolean} options.isPrivateNote - Whether the upload is for a private note
*/
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
@@ -24,57 +21,72 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const maxFileSize = computed(() =>
isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE
);
// helper: compute max upload size for a given file's mime
const maxSizeFor = mime => {
// Use default file size limit for private notes
if (isPrivateNote) {
return MAXIMUM_FILE_UPLOAD_SIZE;
}
return getMaxUploadSizeByChannel({
channelType: inbox?.channel_type,
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
mime, // e.g. 'image/png'
});
};
const alertOverLimit = maxSizeMB =>
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
})
);
const handleDirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
};
const handleIndirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
attachFile({ file });
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
attachFile({ file });
};
const onFileUpload = file => {
@@ -85,7 +97,5 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
}
};
return {
onFileUpload,
};
return { onFileUpload };
};
@@ -77,8 +77,8 @@ export const useFontSize = () => {
* Font size options for select dropdown
* @type {Array<{value: string, label: string}>}
*/
const fontSizeOptions = FONT_SIZE_NAMES.map(name =>
createFontSizeOption(t, name)
const fontSizeOptions = computed(() =>
FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name))
);
/**
@@ -29,21 +29,24 @@ export const INBOX_FEATURE_MAP = {
};
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
* Composable for handling inbox-related functionality
* @param {string|null} inboxId - Optional inbox ID. If not provided, uses current chat's inbox
* @returns {Object} An object containing inbox type checking functions
*/
export const useInbox = () => {
export const useInbox = (inboxId = null) => {
const currentChat = useMapGetter('getSelectedChat');
const inboxGetter = useMapGetter('inboxes/getInboxById');
const inbox = computed(() => {
const inboxId = currentChat.value.inbox_id;
const targetInboxId = inboxId || currentChat.value?.inbox_id;
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
if (!targetInboxId) return null;
return useCamelCase(inboxGetter.value(targetInboxId), { deep: true });
});
const channelType = computed(() => {
return inbox.value.channelType;
return inbox.value?.channelType;
});
const isAPIInbox = computed(() => {
@@ -75,19 +78,19 @@ export const useInbox = () => {
});
const whatsAppAPIProvider = computed(() => {
return inbox.value.provider || '';
return inbox.value?.provider || '';
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
return isAnEmailChannel.value && inbox.value?.provider === 'microsoft';
});
const isAGoogleInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'google';
return isAnEmailChannel.value && inbox.value?.provider === 'google';
});
const isATwilioSMSChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'sms';
});
@@ -96,7 +99,7 @@ export const useInbox = () => {
});
const isATwilioWhatsAppChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'whatsapp';
});
@@ -0,0 +1,161 @@
import { computed, unref } from 'vue';
const CALL_STATUSES = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
NO_ANSWER: 'no-answer',
BUSY: 'busy',
FAILED: 'failed',
COMPLETED: 'completed',
CANCELED: 'canceled',
};
const CALL_DIRECTIONS = {
INBOUND: 'inbound',
OUTBOUND: 'outbound',
};
/**
* Composable for handling voice call status display logic
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
* @returns {Object} UI properties for displaying call status
*/
export function useVoiceCallStatus(statusRef, directionRef) {
const status = computed(() => unref(statusRef)?.toString());
const direction = computed(() => unref(directionRef)?.toString());
// Status group helpers
const isFailedStatus = computed(() =>
[
CALL_STATUSES.NO_ANSWER,
CALL_STATUSES.BUSY,
CALL_STATUSES.FAILED,
].includes(status.value)
);
const isEndedStatus = computed(() =>
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
);
const isOutbound = computed(
() => direction.value === CALL_DIRECTIONS.OUTBOUND
);
const labelKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
}
if (s === CALL_STATUSES.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (s === CALL_STATUSES.NO_ANSWER) {
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.RINGING) {
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (isFailedStatus.value) {
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
}
if (isEndedStatus.value) {
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
}
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const bubbleIconName = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
}
if (isFailedStatus.value) {
return 'i-ph-phone-x-fill';
}
// For ringing/completed/canceled show direction when possible
return isOutbound.value
? 'i-ph-phone-outgoing-fill'
: 'i-ph-phone-incoming-fill';
});
const bubbleIconBg = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS) {
return 'bg-n-teal-9';
}
if (isFailedStatus.value) {
return 'bg-n-ruby-9';
}
if (isEndedStatus.value) {
return 'bg-n-slate-11';
}
// default (e.g., ringing)
return 'bg-n-teal-9 animate-pulse';
});
const listIconColor = computed(() => {
const s = status.value;
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
return 'text-n-teal-9';
}
if (isFailedStatus.value) {
return 'text-n-ruby-9';
}
if (isEndedStatus.value) {
return 'text-n-slate-11';
}
return 'text-n-teal-9';
});
return {
status,
labelKey,
subtextKey,
bubbleIconName,
bubbleIconBg,
listIconColor,
};
}
+1
View File
@@ -1,6 +1,7 @@
export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
@@ -1,4 +1,4 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
import posthog from 'posthog-js';
/**
* AnalyticsHelper class to initialize and track user analytics
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
posthog.init(this.analyticsToken, {
api_host: 'https://app.posthog.com',
capture_pageview: false,
persistence: 'localStorage+cookie',
});
this.analytics = analytics;
this.analytics = posthog;
}
/**
@@ -43,8 +45,7 @@ export class AnalyticsHelper {
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
this.analytics.identify(this.user.id.toString(), {
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
@@ -55,7 +56,7 @@ export class AnalyticsHelper {
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
this.analytics.group('company', currentAccount.id.toString(), {
name: currentAccount.name,
});
}
@@ -71,12 +72,7 @@ export class AnalyticsHelper {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
this.analytics.capture(eventName, properties);
}
/**
@@ -89,9 +85,9 @@ export class AnalyticsHelper {
return;
}
this.analytics.page(params);
this.analytics.capture('$pageview', params);
}
}
// This object is shared across, the init is called in app/javascript/packs/application.js
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
export default new AnalyticsHelper(window.analyticsConfig);
@@ -1,15 +1,11 @@
import helperObject, { AnalyticsHelper } from '../';
vi.mock('@june-so/analytics-next', () => ({
AnalyticsBrowser: {
load: () => [
{
identify: vi.fn(),
track: vi.fn(),
page: vi.fn(),
group: vi.fn(),
},
],
vi.mock('posthog-js', () => ({
default: {
init: vi.fn(),
identify: vi.fn(),
capture: vi.fn(),
group: vi.fn(),
},
}));
@@ -26,12 +22,12 @@ describe('AnalyticsHelper', () => {
});
describe('init', () => {
it('should initialize the analytics browser with the correct token', async () => {
it('should initialize posthog with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize the analytics browser if token is not provided', async () => {
it('should not initialize posthog if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
@@ -43,36 +39,36 @@ describe('AnalyticsHelper', () => {
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
});
it('should call identify on analytics browser with correct arguments', () => {
it('should call identify on posthog with correct arguments', () => {
analyticsHelper.identify({
id: '123',
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '1',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 1,
});
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
'test@example.com',
{
userId: '123',
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
}
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
email: 'test@example.com',
name: 'Test User',
avatar: 'avatar_url',
});
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company',
'1',
{ name: 'Account 1' }
);
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
});
it('should call identify on analytics browser without group', () => {
it('should call identify on posthog without group', () => {
analyticsHelper.identify({
id: '123',
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: '1', name: 'Account 1' }],
account_id: '5',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 5,
});
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
@@ -87,29 +83,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.user = { id: '123' };
analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.user = { id: 123 };
});
it('should call track on analytics browser with correct arguments', () => {
it('should call capture on posthog with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: { prop1: 'value1', prop2: 'value2' },
});
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{ prop1: 'value1', prop2: 'value2' }
);
});
it('should call track on analytics browser with default properties', () => {
it('should call capture on posthog with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
userId: '123',
event: 'Test Event',
properties: {},
});
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event',
{}
);
});
it('should not call track on analytics browser if analytics is not initialized', () => {
it('should not call capture on posthog if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
@@ -118,19 +112,22 @@ describe('AnalyticsHelper', () => {
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { page: vi.fn() };
analyticsHelper.analytics = { capture: vi.fn() };
});
it('should call the analytics.page method with the correct arguments', () => {
it('should call the capture method for pageview with the correct arguments', () => {
const params = {
name: 'Test page',
url: '/test',
};
analyticsHelper.page(params);
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'$pageview',
params
);
});
it('should not call analytics.page if analytics is null', () => {
it('should not call analytics.capture if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null);
@@ -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;
}
};
@@ -96,3 +96,18 @@ export const sanitizeVariableSearchKey = (searchKey = '') => {
.replace(/,/g, '') // remove commas
.trim();
};
/**
* Convert underscore-separated string to title case.
* Eg. "round_robin" => "Round Robin"
* @param {string} str
* @returns {string}
*/
export const formatToTitleCase = str => {
return (
str
?.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.trim() || ''
);
};
@@ -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');
});
});
});
@@ -5,6 +5,7 @@ import {
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
formatToTitleCase,
} from '../commons';
describe('#getTypingUsersText', () => {
@@ -142,3 +143,51 @@ describe('sanitizeVariableSearchKey', () => {
expect(sanitizeVariableSearchKey()).toBe('');
});
});
describe('formatToTitleCase', () => {
it('converts underscore-separated string to title case', () => {
expect(formatToTitleCase('round_robin')).toBe('Round Robin');
});
it('converts single word to title case', () => {
expect(formatToTitleCase('priority')).toBe('Priority');
});
it('converts multiple underscores to title case', () => {
expect(formatToTitleCase('auto_assignment_policy')).toBe(
'Auto Assignment Policy'
);
});
it('handles already capitalized words', () => {
expect(formatToTitleCase('HIGH_PRIORITY')).toBe('HIGH PRIORITY');
});
it('handles mixed case with underscores', () => {
expect(formatToTitleCase('first_Name_last')).toBe('First Name Last');
});
it('handles empty string', () => {
expect(formatToTitleCase('')).toBe('');
});
it('handles null input', () => {
expect(formatToTitleCase(null)).toBe('');
});
it('handles undefined input', () => {
expect(formatToTitleCase(undefined)).toBe('');
});
it('handles string without underscores', () => {
expect(formatToTitleCase('hello')).toBe('Hello');
});
it('handles string with numbers', () => {
expect(formatToTitleCase('priority_1_high')).toBe('Priority 1 High');
});
it('handles leading and trailing underscores', () => {
expect(formatToTitleCase('_leading_trailing_')).toBe('Leading Trailing');
});
});
@@ -17,6 +17,11 @@
"IP_ADDRESS": "IP Address",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "ደውል",
"CALL_UNDER_DEVELOPMENT": "መደወል በልማት ላይ ነው",
"VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
},
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations"
@@ -285,7 +290,7 @@
"HEADER": {
"TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"ACTIVE_TITLE": "ንቁ እውቂያዎች",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
@@ -460,8 +465,8 @@
}
},
"DELETE_CONTACT": {
"MESSAGE": "This action is permanent and irreversible.",
"BUTTON": "Delete now"
"MESSAGE": "ይህ እርምጃ ቋሚ ነው እና መመለስ አይቻልም።",
"BUTTON": "አሁን ሰርዝ"
}
},
"DETAILS": {
@@ -471,7 +476,7 @@
"DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": {
"TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this contact?",
"DESCRIPTION": "ይህን እውቂያ ማጥፋት እርግጠኛ ነዎት?",
"CONFIRM": "Yes, Delete",
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
@@ -550,8 +555,8 @@
"YOU": "You",
"SAVE": "Save note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"COLLAPSE": "ሰብስብ",
"NO_NOTES": "ማስታወሻዎች የሉም፣ ከእውቂያው ዝርዝር ገፅ ላይ ማስታወሻዎችን መጨመር ይችላሉ።",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
}
},
@@ -561,7 +566,7 @@
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
}
},
"COMPOSE_NEW_CONVERSATION": {
@@ -605,6 +610,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"
}
}
}
@@ -32,12 +32,17 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{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.",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -70,7 +75,7 @@
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
@@ -123,8 +128,8 @@
}
},
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"TITLE": "ውይይት #{conversationId}ን ሰርዝ",
"DESCRIPTION": "ይህን ውይይት ለመሰረዝ እርግጠኛ ነዎት?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": {
@@ -143,10 +148,10 @@
"ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"DELETE": "ውይይት ሰርዝ",
"OPEN_IN_NEW_TAB": "በአዲስ ታብ ክፈት",
"COPY_LINK": "የውይይት አገናኝን ኮፒ አድርግ",
"COPY_LINK_SUCCESS": "የውይይት አገናኝ ወደ ቅጂ ሰሌዳ ኮፒ ተደረገ",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -221,8 +226,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
@@ -311,11 +316,11 @@
"CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_NOTES": "የእውቂያ ማስታወሻዎች",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
@@ -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..."
}
}
}
@@ -330,13 +330,14 @@
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"CONFIGURATION": {
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
@@ -423,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",
@@ -477,7 +522,10 @@
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting",
"BUTTON_TEXT": "Take me there",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox.",
"WHATSAPP_QR_INSTRUCTION": "Scan the QR code above to quickly test your WhatsApp inbox",
"MESSENGER_QR_INSTRUCTION": "Scan the QR code above to quickly test your Facebook Messenger inbox",
"TELEGRAM_QR_INSTRUCTION": "Scan the QR code above to quickly test your Telegram inbox"
},
"REAUTH": "Reauthorize",
"VIEW": "View",
@@ -867,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",
@@ -704,11 +704,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",
@@ -226,6 +226,7 @@
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"CHANGELOG": "Changelog",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
@@ -17,6 +17,11 @@
"IP_ADDRESS": "عنوان IP",
"CREATED_AT_LABEL": "تم إنشاؤها",
"NEW_MESSAGE": "رسالة جديدة",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
"TITLE": "المحادثات السابقة"
@@ -605,6 +610,15 @@
"SEND_MESSAGE": "إرسال الرسالة"
}
},
"TWILIO_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"BACK": "العودة للخلف",
"SEND_MESSAGE": "إرسال الرسالة"
}
},
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
@@ -48,7 +48,8 @@
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "رابط المرجع",
"BLOCKED": "محظور"
"BLOCKED": "محظور",
"LABELS": "الوسوم"
},
"GROUPS": {
"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": "نماذج البحث",
"NO_TEMPLATES_FOUND": "لم يتم العثور على قوالب",
"NO_CONTENT": "لا يوجد محتوى",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"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": "اللغة",
"TEMPLATE_BODY": "نص القالب",
"CATEGORY": "الفئة"
},
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"TEXT": "النص"
}
},
"PARSER": {
"VARIABLES_LABEL": "المتغيرات",
"LANGUAGE": "اللغة",
"CATEGORY": "الفئة",
"VARIABLE_PLACEHOLDER": "أدخل قيمة {variable}",
"GO_BACK_LABEL": "العودة للخلف",
"SEND_MESSAGE_LABEL": "إرسال الرسالة",
"FORM_ERROR_MESSAGE": "يرجى ملء جميع المتغيرات قبل الإرسال",
"MEDIA_HEADER_LABEL": "{type} Header",
"MEDIA_URL_LABEL": "Enter full media URL",
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
},
"FORM": {
"BACK_BUTTON": "العودة",
"SEND_MESSAGE_BUTTON": "إرسال الرسالة"
}
}
}
@@ -35,6 +35,11 @@
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "لم يتم تعيين هذه المحادثة لك. هل ترغب في تعيين هذه المحادثة لنفسك؟",
"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": "يمكنك فقط الرد على هذه المحادثة باستخدام رسالة قالب بسبب",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "قيد نافذة الـ 24 ساعة",
"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": "جاري الرفع...",
"UPLOAD": "Upload & Process",
"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": "مكتمل",
"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..."
}
}
}
@@ -330,13 +330,14 @@
"LABEL": "مفتاح سر API",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"CONFIGURATION": {
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
@@ -423,7 +424,51 @@
},
"AUTH": {
"TITLE": "اختر قناة",
"DESC": "يدعم أدوات الدردشة الحية، والميسنجر الفيسبوك، وملفات التويتر الشخصية، و WhatsApp، ورسائل البريد الإلكتروني، إلخ، كقنوات. إذا كنت ترغب في إنشاء قناة مخصصة، يمكنك إنشاءها باستخدام قناة API. للبدء، اختر إحدى القنوات أدناه."
"DESC": "يدعم أدوات الدردشة الحية، والميسنجر الفيسبوك، وملفات التويتر الشخصية، و WhatsApp، ورسائل البريد الإلكتروني، إلخ، كقنوات. إذا كنت ترغب في إنشاء قناة مخصصة، يمكنك إنشاءها باستخدام قناة API. للبدء، اختر إحدى القنوات أدناه.",
"TITLE_NEXT": "Complete the setup",
"TITLE_FINISH": "Voilà!",
"CHANNEL": {
"WEBSITE": {
"TITLE": "الموقع الإلكتروني",
"DESCRIPTION": "Create a live-chat widget"
},
"FACEBOOK": {
"TITLE": "فيسبوك",
"DESCRIPTION": "Connect your Facebook page"
},
"WHATSAPP": {
"TITLE": "واتساب",
"DESCRIPTION": "Support your customers on WhatsApp"
},
"EMAIL": {
"TITLE": "البريد الإلكتروني",
"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": "تيليجرام",
"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": "وكيل الدعم",
@@ -477,7 +522,10 @@
"MESSAGE": "يمكنك الآن التواصل مع عملائك من خلال قناتك الجديدة",
"BUTTON_TEXT": "خذني إلى هناك",
"MORE_SETTINGS": "المزيد من الإعدادات",
"WEBSITE_SUCCESS": "لقد انتهيت بنجاح من إنشاء قناة دردشة مباشرة لموقعك. انسخ الرمز الموضح أدناه وقم بإضافته إلى موقع الويب الخاص بك. في المرة القادمة التي يستخدم فيها العميل الدردشة المباشرة، ستظهر المحادثة تلقائياً على صندوق الوارد الخاص بك."
"WEBSITE_SUCCESS": "لقد انتهيت بنجاح من إنشاء قناة دردشة مباشرة لموقعك. انسخ الرمز الموضح أدناه وقم بإضافته إلى موقع الويب الخاص بك. في المرة القادمة التي يستخدم فيها العميل الدردشة المباشرة، ستظهر المحادثة تلقائياً على صندوق الوارد الخاص بك.",
"WHATSAPP_QR_INSTRUCTION": "Scan the QR code above to quickly test your WhatsApp inbox",
"MESSENGER_QR_INSTRUCTION": "Scan the QR code above to quickly test your Facebook Messenger inbox",
"TELEGRAM_QR_INSTRUCTION": "Scan the QR code above to quickly test your Telegram inbox"
},
"REAUTH": "إعادة التصريح",
"VIEW": "عرض",
@@ -867,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",
@@ -704,11 +704,28 @@
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"TYPE": {
"LABEL": "Document Type",
"URL": "الرابط",
"PDF": "PDF File"
},
"URL": {
"LABEL": "الرابط",
"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",
@@ -226,6 +226,7 @@
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"CHANGELOG": "Changelog",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
@@ -17,6 +17,11 @@
"IP_ADDRESS": "IP Address",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations"
@@ -605,6 +610,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",

Some files were not shown because too many files have changed in this diff Show More