Merge branch 'develop' into feat/widget-v2

This commit is contained in:
Sivin Varghese
2025-03-04 10:20:34 +05:30
committed by GitHub
2153 changed files with 110936 additions and 39365 deletions
+9 -1
View File
@@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -37,8 +38,15 @@ export default {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
return { router, store, currentAccountId: accountId };
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
};
},
data() {
return {
@@ -0,0 +1,19 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainAssistant();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainBulkActionsAPI extends ApiClient {
constructor() {
super('captain/bulk_actions', { accountScoped: true });
}
}
export default new CaptainBulkActionsAPI();
@@ -0,0 +1,20 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainDocument extends ApiClient {
constructor() {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
},
});
}
}
export default new CaptainDocument();
@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxes extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId } = {}) {
return axios.get(`${this.url}/${assistantId}/inboxes`);
}
create(params = {}) {
const { assistantId, inboxId } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
});
}
delete(params = {}) {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
}
export default new CaptainInboxes();
@@ -0,0 +1,22 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainResponses extends ApiClient {
constructor() {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
document_id: documentId,
status,
},
});
}
}
export default new CaptainResponses();
@@ -133,6 +133,14 @@ class ConversationApi extends ApiClient {
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
}
export default new ConversationApi();
@@ -32,14 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
requestCaptainCopilot(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
}
}
export default new IntegrationsAPI();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV() {
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
});
}
+6 -3
View File
@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
},
});
}
conversations({ q }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
},
});
}
messages({ q }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
},
});
}
@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -1,7 +1,7 @@
.dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999];
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
&.dropdown-pane--open {
@apply bg-white absolute dark:bg-slate-800 block visible;
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
}
}
@@ -6,3 +6,209 @@
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
@layer base {
// FIXME: Use a common color file for all packs
// scss-lint:disable PropertySortOrder
:root {
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
}
input[type='file'] {
@apply bg-white dark:bg-slate-800 leading-[1.15] mb-4;
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
}
// Select
@@ -141,11 +141,16 @@ code {
@apply text-xs border-0;
&.hljs {
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5;
@apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
.hljs-number,
.hljs-string {
@apply text-red-800 dark:text-red-400;
}
.hljs-name,
.hljs-tag {
@apply text-n-slate-11;
}
}
}
@@ -31,7 +31,7 @@ button {
}
.button {
@apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
@@ -42,8 +42,10 @@ button {
}
}
&:hover {
@apply bg-woot-600 dark:bg-woot-600;
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
}
&:disabled,
@@ -52,23 +54,23 @@ button {
}
&.success {
@apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white;
@apply bg-n-teal-9 text-white dark:text-white;
}
&.secondary {
@apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white;
@apply bg-n-solid-3 text-white dark:text-white;
}
&.primary {
@apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white;
@apply bg-n-brand text-white dark:text-white;
}
&.clear {
@apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent;
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-red-500 dark:bg-red-500 text-white dark:text-white;
@apply bg-n-ruby-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -76,7 +78,7 @@ button {
}
&.warning {
@apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white;
@apply bg-n-amber-9 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -115,114 +117,74 @@ button {
}
&.hollow {
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
&.secondary {
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
}
&.success {
@apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
}
&.alert {
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
}
&.warning {
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
}
&.success {
@apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100;
}
&.alert {
@apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100;
}
&.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
}
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900;
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
&.secondary {
@apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800;
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
}
&.success {
@apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
}
&.alert {
@apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30;
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
}
&.warning {
@apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200;
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
}
}
&.clear {
@apply text-woot-500 dark:text-woot-500;
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
&.secondary {
@apply text-slate-700 dark:text-slate-100;
@apply text-n-slate-12 hover:bg-n-slate-4;
}
&.success {
@apply text-green-700 dark:text-green-100;
@apply text-n-teal-10 hover:bg-n-teal-4;
}
&.alert {
@apply text-red-700 dark:text-red-100;
@apply text-n-ruby-11 hover:bg-n-ruby-4;
}
&.warning {
@apply text-yellow-700 dark:text-yellow-600;
}
&:hover {
@apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100;
&.secondary {
@apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100;
}
&.success {
@apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
}
&.alert {
@apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100;
}
&.warning {
@apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600;
}
@apply text-n-amber-11 hover:bg-n-amber-4;
}
&:active {
&.secondary {
@apply active:bg-slate-100 dark:active:bg-slate-900;
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
}
}
&:focus {
&.secondary {
@apply focus:bg-slate-50 dark:focus:bg-slate-700;
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
}
}
}
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-slate-50 dark:border-slate-800/50;
@apply border-b border-n-weak;
}
.tabs--container--compact.tab--chat-type {
@@ -42,7 +42,7 @@
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@@ -56,22 +56,22 @@
&:hover,
&:focus {
a {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-12;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
@apply border-b border-n-brand text-n-blue-text;
}
.badge {
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
}
}
}
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
v-dompurify-html="formatMessage(message, false, false, false)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
@@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
@@ -39,7 +39,7 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
@@ -4,6 +4,10 @@ defineProps({
type: String,
default: 'col',
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
@@ -18,10 +22,11 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick"
>
<slot />
@@ -8,17 +8,17 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
isUpdating: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n();
const slots = useSlots();
@@ -45,9 +45,17 @@ const breadcrumbItems = computed(() => {
return items;
});
const isContactBlocked = computed(() => {
return props.selectedContact?.blocked;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
</script>
<template>
@@ -57,23 +65,41 @@ const handleBreadcrumbClick = () => {
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
<div class="flex items-center gap-2">
<Button
:label="
!isContactBlocked
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot name="default" />
</div>
</main>
@@ -81,7 +107,7 @@ const handleBreadcrumbClick = () => {
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
@@ -50,6 +50,7 @@ const { t } = useI18n();
</div>
<ComboBox
id="inbox"
use-api-results
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
@@ -1,7 +1,7 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
@@ -35,7 +35,7 @@ const FORM_CONFIG = {
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
COUNTRY: { field: 'additionalAttributes.countryCode' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
@@ -74,7 +74,7 @@ const defaultState = {
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required, minLength: minLength(2) },
firstName: { required },
email: { email },
};
@@ -123,7 +123,7 @@ const prepareStateBasedOnProps = () => {
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
@@ -205,8 +205,8 @@ const getMessageType = key => {
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.name === value);
state.additionalAttributes.countryCode = selectedCountry?.id || '';
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state);
};
@@ -242,7 +242,7 @@ defineExpose({
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
@@ -59,6 +59,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
@@ -60,7 +60,7 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -72,7 +72,7 @@ const openFilter = () => {
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
@@ -14,6 +15,8 @@ const props = defineProps({
const { t } = useI18n();
const { uiSettings } = useUISettings();
const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
@@ -46,20 +49,49 @@ const processContactAttributes = (
}, []);
};
const sortAttributesOrder = computed(
() =>
uiSettings.value.conversation_elements_order_conversation_contact_panel ??
[]
);
const sortByUISettings = attributes => {
// Get saved order from UI settings
// Same as conversation panel contact attribute order
const order = sortAttributesOrder.value;
// If no order defined, return original array
if (!order?.length) return attributes;
const orderMap = new Map(order.map((key, index) => [key, index]));
// Sort attributes based on their position in saved order
return [...attributes].sort((a, b) => {
// Get positions, use Infinity if not found in order (pushes to end)
const aPos = orderMap.get(a.attributeKey) ?? Infinity;
const bPos = orderMap.get(b.attributeKey) ?? Infinity;
return aPos - bPos;
});
};
const usedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
return sortByUISettings(attributes);
});
const unusedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
return sortByUISettings(attributes);
});
const filteredUnusedAttributes = computed(() => {
@@ -29,7 +29,7 @@ const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user.name;
: note?.user?.name || 'Bot';
};
const onAdd = content => {
@@ -33,8 +33,8 @@ const handleDelete = () => {
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:name="note?.user?.name || 'Bot'"
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
:size="16"
rounded-full
/>
@@ -21,7 +21,7 @@ const lastNonActivityMessageContent = computed(() => {
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
@@ -29,7 +29,7 @@ const lastNonActivityMessageContent = computed(() => {
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage.content || t('CHAT_LIST.NO_CONTENT')
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
@@ -97,7 +97,7 @@ const onCardClick = e => {
:status="currentContactStatus"
rounded-full
/>
<div class="flex flex-col w-full gap-1">
<div class="flex flex-col w-full gap-1 min-w-0">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
@@ -41,6 +41,8 @@ const props = defineProps({
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
});
const emit = defineEmits(['update:modelValue']);
@@ -116,6 +118,8 @@ watch(
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -1,4 +1,6 @@
<script setup>
import Policy from 'dashboard/components/policy.vue';
defineProps({
title: {
type: String,
@@ -8,6 +10,10 @@ defineProps({
type: String,
required: true,
},
actionPerms: {
type: Array,
default: () => [],
},
});
</script>
@@ -16,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
@@ -39,7 +45,9 @@ defineProps({
{{ subtitle }}
</p>
</div>
<slot name="actions" />
<Policy :permissions="actionPerms">
<slot name="actions" />
</Policy>
</div>
</div>
</div>
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -36,6 +36,8 @@ const emit = defineEmits([
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
@@ -52,21 +54,32 @@ const quickSave = debounce(
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({
get: () => props.article.title,
set: value => {
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
quickSave({ content });
saveAndSyncDebounced({ content });
handleSave({ content });
},
});
@@ -200,7 +200,8 @@ onMounted(() => {
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -231,7 +232,8 @@ onMounted(() => {
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
@@ -75,7 +75,7 @@ onMounted(() => {
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
>
{{
t(
@@ -90,9 +90,9 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[220px]"
class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
custom-text-area-class="max-h-[9.375rem]"
auto-height
min-height="3rem"
/>
@@ -108,12 +108,12 @@ onMounted(() => {
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
custom-label-class="min-w-[7.5rem]"
/>
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@@ -126,7 +126,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
class="w-[14rem]"
/>
</div>
</div>
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
ARTICLE_TABS,
CATEGORY_ALL,
@@ -37,6 +38,7 @@ const emit = defineEmits([
const route = useRoute();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false);
@@ -111,13 +113,12 @@ const localeMenuItems = computed(() => {
}));
});
const hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
updateUISettings({
last_active_locale_code: value,
});
};
const handleCategoryAction = ({ value }) => {
@@ -143,7 +144,7 @@ const handleTabChange = value => {
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
@@ -157,6 +158,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -177,6 +179,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
@@ -99,11 +99,19 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateMeta = () => {
const updatePortalMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale });
};
const updateArticlesMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('articles/updateArticleMeta', {
portalSlug,
locale,
});
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -127,7 +135,8 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateMeta();
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const errorMessage =
error?.message ||
@@ -91,10 +91,7 @@ const articlesCount = computed(() => {
});
const showArticleHeaderControls = computed(
() =>
!hasNoArticlesInPortal.value &&
!props.isCategoryArticles &&
!isSwitchingPortal.value
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
const showCategoryHeaderControls = computed(
@@ -90,7 +90,7 @@ const handleCategory = async formData => {
<template>
<div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{
@@ -201,7 +201,7 @@ defineExpose({ state, isSubmitDisabled });
color="slate"
size="sm"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -141,6 +141,7 @@ const handleBreadcrumbClick = () => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -49,8 +49,11 @@ const onCreate = async () => {
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: { allowed_locales: updatedLocales },
portalSlug: props.portal?.slug,
config: {
allowed_locales: updatedLocales,
default_locale: props.portal?.meta?.default_locale,
},
});
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
@@ -2,6 +2,7 @@
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -20,6 +21,7 @@ const props = defineProps({
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -56,26 +58,40 @@ const changeDefaultLocale = ({ localeCode }) => {
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updateLastActivePortal = async localeCode => {
const { last_active_locale_code: lastActiveLocaleCode } =
uiSettings.value || {};
const defaultLocale = props.portal.meta.default_locale;
// Update UI settings only if deleting locale matches the last active locale in UI settings.
if (localeCode === lastActiveLocaleCode) {
await updateUISettings({
last_active_locale_code: defaultLocale,
});
}
};
const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
await updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
await updateLastActivePortal(localeCode);
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
@@ -74,7 +74,7 @@ const handleDeletePortal = () => {
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -21,7 +21,7 @@ const showDropdown = ref(false);
<template>
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-6 outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="showDropdown = !showDropdown"
>
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
@@ -12,6 +13,7 @@ import {
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -30,6 +32,8 @@ const props = defineProps({
const store = useStore();
const { t } = useI18n();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -43,6 +47,12 @@ const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
);
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
@@ -96,7 +106,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
// Merge the processed contactableInboxes with the inboxesList
selectedContact.value.contactInboxes = mergeInboxDetails(
contactableInboxes,
inboxesList.value
);
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
@@ -154,15 +169,22 @@ watch(
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(contactInboxes);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
},
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
showComposeNewConversation.value = false;
};
onMounted(() => resetContacts());
const keyboardEvents = {
@@ -180,8 +202,16 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
}"
>
<slot
name="trigger"
@@ -202,6 +232,8 @@ useKeyboardEvents(keyboardEvents);
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@@ -1,6 +1,5 @@
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { defineAsyncComponent, ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
@@ -8,51 +7,24 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: {
type: Array,
default: () => [],
},
isWhatsappInbox: {
type: Boolean,
default: false,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
default: false,
},
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
default: () => [],
},
channelType: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
disableSendButton: {
type: Boolean,
default: false,
},
hasNoInbox: {
type: Boolean,
default: false,
},
isDropdownActive: {
type: Boolean,
default: false,
},
attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disableSendButton: { type: Boolean, default: false },
hasSelectedInbox: { type: Boolean, default: false },
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
});
const emit = defineEmits([
@@ -74,8 +46,11 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const messageSignature = useMapGetter('getMessageSignature');
const signatureToApply = computed(() => messageSignature.value);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
@@ -87,13 +62,9 @@ const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const isSignatureEnabledForInbox = computed(() => {
return props.isEmailOrWebWidgetInbox && sendWithSignature.value;
});
const setSignature = () => {
if (signatureToApply.value) {
if (isSignatureEnabledForInbox.value) {
if (sendWithSignature.value) {
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', signatureToApply.value);
@@ -106,6 +77,19 @@ const toggleMessageSignature = () => {
setSignature();
};
// Added this watch to dynamically set signature on target inbox change.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true
watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
},
{ immediate: true }
);
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
@@ -164,7 +148,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
@@ -213,7 +197,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="isEmailOrWebWidgetInbox"
v-if="hasSelectedInbox && !isWhatsappInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -47,10 +47,10 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
class="relative group/image w-[4.5rem] h-[4.5rem]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
:src="attachment.thumb"
/>
<Button
@@ -69,7 +69,7 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -33,6 +34,8 @@ const props = defineProps({
isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -184,6 +187,14 @@ const handleInboxAction = ({ value, action, ...rest }) => {
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove);
}
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -254,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -302,6 +313,8 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
@@ -322,8 +335,10 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@@ -1,22 +1,22 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
defineProps({
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
hasAttachments: {
type: Boolean,
default: false,
},
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
});
const { t } = useI18n();
@@ -25,41 +25,98 @@ const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, plainTextSignature.value)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div
v-if="isEmailOrWebWidgetInbox"
class="flex-1 h-full"
:class="!hasAttachments && 'min-h-[200px]'"
>
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
:show-character-count="false"
/>
</div>
<div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'">
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
auto-height
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
/>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
</div>
</template>
@@ -87,6 +87,21 @@ export const processContactableInboxes = inboxes => {
}));
};
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
@@ -110,6 +110,153 @@ describe('composeConversationHelper', () => {
});
});
describe('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
@@ -174,7 +174,7 @@ watch(
</script>
<template>
<span class="relative inline-flex group/avatar" :style="containerStyles">
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
color: {
type: String,
default: 'slate',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
actionLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const triggerAction = () => {
emit('action');
};
</script>
<template>
<div
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
:class="[
bannerClass,
{
'py-2 px-3': !actionLabel,
'pl-3 p-2': actionLabel,
},
]"
>
<div>
<slot />
</div>
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
{{ actionLabel }}
</button>
</div>
</div>
</template>
@@ -6,6 +6,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
VARIANT_OPTIONS,
COLOR_OPTIONS,
JUSTIFY_OPTIONS,
SIZE_OPTIONS,
EXCLUDED_ATTRS,
} from './constants.js';
@@ -27,6 +28,11 @@ const props = defineProps({
default: null,
validator: value => SIZE_OPTIONS.includes(value) || value === null,
},
justify: {
type: String,
default: null,
validator: value => JUSTIFY_OPTIONS.includes(value) || value === null,
},
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
@@ -81,48 +87,62 @@ const computedSize = computed(() => {
return 'md';
});
const computedJustify = computed(() => {
if (props.justify) return props.justify;
if (attrs.start || attrs.start === '') return 'start';
if (attrs.center || attrs.center === '') return 'center';
if (attrs.end || attrs.end === '') return 'end';
return 'center';
});
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
solid:
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
solid:
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent',
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
},
amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
solid:
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
outline:
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -151,7 +171,12 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium',
lg: 'text-base',
},
base: 'inline-flex items-center justify-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
justify: {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
},
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
};
const variantClasses = computed(() => {
@@ -197,6 +222,7 @@ const linkButtonClasses = computed(() => {
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true,
[STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
>
@@ -1,6 +1,7 @@
export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
export const JUSTIFY_OPTIONS = ['start', 'center', 'end'];
export const EXCLUDED_ATTRS = [
'variant',
@@ -12,4 +13,5 @@ export const EXCLUDED_ATTRS = [
...VARIANT_OPTIONS,
...COLOR_OPTIONS,
...SIZE_OPTIONS,
...JUSTIFY_OPTIONS,
];
@@ -0,0 +1,126 @@
<script setup>
import { computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
headerTitle: {
type: String,
default: '',
},
buttonPolicy: {
type: Array,
default: () => [],
},
buttonLabel: {
type: String,
default: '',
},
featureFlag: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
showPaginationFooter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { shouldShowPaywall } = usePolicy();
const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag);
});
const handleButtonClick = () => {
emit('click');
};
const handlePageChange = event => {
emit('update:currentPage', event);
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
<slot name="headerTitle" />
</span>
<div
v-if="!showPaywall"
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Policy :permissions="buttonPolicy">
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
</Policy>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="showPaywall">
<slot name="paywall" />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</template>
@@ -0,0 +1,27 @@
<script setup>
import AssistantCard from './AssistantCard.vue';
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
</script>
<template>
<Story
title="Captain/Assistant/AssistantCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Assistant Card">
<div
v-for="(assistant, index) in assistantsList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<AssistantCard
:id="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
updatedAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
value: 'viewConnectedInboxes',
action: 'viewConnectedInboxes',
icon: 'i-lucide-link',
},
];
if (checkPermissions(['administrator'])) {
allOptions.push(
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
}
);
}
return allOptions;
});
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<span class="text-sm truncate text-n-slate-11">
{{ description || 'Description not available' }}
</span>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ lastUpdatedAt }}
</span>
</div>
</CardLayout>
</template>
@@ -0,0 +1,27 @@
<script setup>
import DocumentCard from './DocumentCard.vue';
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
</script>
<template>
<Story
title="Captain/Assistant/DocumentCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Document Card">
<div
v-for="(doc, index) in documentsList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<DocumentCard
:id="doc.id"
:name="doc.name"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,117 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
default: '',
},
assistant: {
type: Object,
default: () => ({}),
},
externalLink: {
type: String,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
];
if (checkPermissions(['administrator'])) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
});
}
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<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"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<span
class="text-sm shrink-0 truncate text-n-slate-11 flex items-center gap-1"
>
<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"
>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,21 @@
<script setup>
import InboxCard from './InboxCard.vue';
import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
</script>
<template>
<Story
title="Captain/Assistant/InboxCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Inbox Card">
<div
v-for="inbox in inboxes"
:key="inbox.id"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<InboxCard :id="inbox.id" :inbox="inbox" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
const props = defineProps({
id: {
type: Number,
required: true,
},
inbox: {
type: Object,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const inboxName = computed(() => {
const inbox = props.inbox;
if (!inbox?.name) {
return '';
}
const isTwilioChannel = inbox.channel_type === INBOX_TYPES.TWILIO;
const isWhatsAppChannel = inbox.channel_type === INBOX_TYPES.WHATSAPP;
const isEmailChannel = inbox.channel_type === INBOX_TYPES.EMAIL;
if (isTwilioChannel || isWhatsAppChannel) {
const identifier = inbox.messaging_service_sid || inbox.phone_number;
return identifier ? `${inbox.name} (${identifier})` : inbox.name;
}
if (isEmailChannel && inbox.email) {
return `${inbox.name} (${inbox.email})`;
}
return inbox.name;
});
const menuItems = computed(() => [
{
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base text-n-slate-12 line-clamp-1 flex items-center gap-2"
>
<span :class="icon" />
{{ inboxName }}
</span>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</Policy>
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,28 @@
<script setup>
import ResponseCard from './ResponseCard.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
</script>
<template>
<Story
title="Captain/Assistant/ResponseCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Article Card">
<div
v-for="(response, index) in responsesList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<ResponseCard
:id="response.id"
:question="response.question"
:answer="response.answer"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,227 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
question: {
type: String,
required: true,
},
answer: {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
status: {
type: String,
default: 'approved',
},
documentable: {
type: Object,
default: null,
},
assistant: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
required: true,
},
createdAt: {
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const statusAction = computed(() => {
if (props.status === 'pending') {
return [
{
label: t('CAPTAIN.RESPONSES.OPTIONS.APPROVE'),
value: 'approve',
action: 'approve',
icon: 'i-lucide-circle-check-big',
},
];
}
return [];
});
const menuItems = computed(() => [
...statusAction.value,
{
label: t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAssistantAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleDocumentableClick = () => {
emit('navigate', {
id: props.documentable.id,
type: props.documentable.type,
});
};
</script>
<template>
<CardLayout
selectable
class="relative"
:class="{ 'rounded-md': compact }"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAssistantAction($event)"
/>
</Policy>
</div>
</div>
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
>
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,68 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const isFilterOpen = ref(false);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => [
{
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
value: 'all',
action: 'filter',
},
...assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
action: 'filter',
})),
]);
const selectedAssistantLabel = computed(() => {
const assistant = assistantOptions.value.find(
option => option.value === props.assistantId
);
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
selected: assistant ? assistant.label : '',
});
});
const handleAssistantFilterChange = ({ value }) => {
isFilterOpen.value = false;
emit('update', value);
};
</script>
<template>
<OnClickOutside @trigger="isFilterOpen = false">
<Button
:label="selectedAssistantLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isFilterOpen = !isFilterOpen"
/>
<DropdownMenu
v-if="isFilterOpen"
:menu-items="assistantOptions"
class="mt-2"
@action="handleAssistantFilterChange"
/>
</OnClickOutside>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
type: {
type: String,
required: true,
},
bulkIds: {
type: Object,
required: true,
},
});
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.ERROR_MESSAGE`));
}
};
const handleDialogConfirm = async () => {
await handleBulkDelete(Array.from(props.bulkIds));
bulkDeleteDialogRef.value?.close();
};
defineExpose({ dialogRef: bulkDeleteDialogRef });
</script>
<template>
<Dialog
ref="bulkDeleteDialogRef"
type="alert"
:title="t(`CAPTAIN.${i18nKey}.BULK_DELETE.TITLE`)"
:description="t(`CAPTAIN.${i18nKey}.BULK_DELETE.DESCRIPTION`)"
:confirm-button-label="t(`CAPTAIN.${i18nKey}.BULK_DELETE.CONFIRM`)"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
type: {
type: String,
required: true,
},
entity: {
type: Object,
required: true,
},
deletePayload: {
type: Object,
default: null,
},
});
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const deleteEntity = async payload => {
if (!payload) return;
try {
await store.dispatch(`captain${props.type}/delete`, payload);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.ERROR_MESSAGE`));
}
};
const handleDialogConfirm = async () => {
await deleteEntity(props.deletePayload || props.entity.id);
deleteDialogRef.value?.close();
};
defineExpose({ dialogRef: deleteDialogRef });
</script>
<template>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t(`CAPTAIN.${i18nKey}.DELETE.TITLE`)"
:description="t(`CAPTAIN.${i18nKey}.DELETE.DESCRIPTION`)"
:confirm-button-label="t(`CAPTAIN.${i18nKey}.DELETE.CONFIRM`)"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>
@@ -0,0 +1,174 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
featureFaq: false,
featureMemory: false,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.ASSISTANTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name', 'NAME'),
description: getErrorMessage('description', 'DESCRIPTION'),
productName: getErrorMessage('productName', 'PRODUCT_NAME'),
}));
const handleCancel = () => emit('cancel');
const prepareAssistantDetails = () => ({
name: state.name,
description: state.description,
config: {
product_name: state.productName,
feature_faq: state.featureFaq,
feature_memory: state.featureMemory,
},
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareAssistantDetails());
};
const updateStateFromAssistant = assistant => {
if (!assistant) return;
const { name, description, config } = assistant;
Object.assign(state, {
name,
description,
productName: config.product_name,
featureFaq: config.feature_faq || false,
featureMemory: config.feature_memory || false,
});
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-3 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</legend>
<label class="flex items-center gap-2">
<input v-model="state.featureFaq" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.featureMemory" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</span>
</label>
</fieldset>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t(`CAPTAIN.FORM.${mode.toUpperCase()}`)"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import AssistantForm from './AssistantForm.vue';
const props = defineProps({
selectedAssistant: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const assistantForm = ref(null);
const updateAssistant = assistantDetails =>
store.dispatch('captainAssistants/update', {
id: props.selectedAssistant.id,
...assistantDetails,
});
const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
);
const createAssistant = assistantDetails =>
store.dispatch('captainAssistants/create', assistantDetails);
const handleSubmit = async updatedAssistant => {
try {
if (props.type === 'edit') {
await updateAssistant(updatedAssistant);
} else {
await createAssistant(updatedAssistant);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t(`${i18nKey}.TITLE`)"
:description="t('CAPTAIN.ASSISTANTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
@close="handleClose"
>
<AssistantForm
ref="assistantForm"
:mode="type"
:assistant="selectedAssistant"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<DocumentForm
ref="documentForm"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
name: '',
assistantId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
url: { required, url, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareDocumentDetails());
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
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 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') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
: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"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { documentLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!documentLimits.value) return false;
const { currentAvailable } = documentLimits.value;
return currentAvailable === 0;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
</Banner>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>
@@ -0,0 +1,40 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<AssistantCard
v-for="(assistant, index) in assistantsList.slice(0, 5)"
:id="assistant.id"
:key="`assistant-${index}`"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.created_at"
/>
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,41 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<DocumentCard
v-for="(document, index) in documentsList.slice(0, 5)"
:id="document.id"
:key="`document-${index}`"
:name="document.name"
:assistant="document.assistant"
:external-link="document.external_link"
:created-at="document.created_at"
/>
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,38 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<InboxCard
v-for="(inbox, index) in inboxes.slice(0, 5)"
:id="inbox.id"
:key="`inbox-${index}`"
:inbox="inbox"
/>
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.INBOXES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,43 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
:key="`response-${index}`"
:question="response.question"
:answer="response.answer"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.created_at"
/>
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,286 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const assistantsList = [
{
account_id: 2,
config: { product_name: 'HelpDesk Pro' },
created_at: 1736033561,
description:
'An advanced AI assistant designed to enhance customer support solutions by automating workflows and providing instant responses.',
id: 4,
name: 'Support Genie',
},
{
account_id: 3,
config: { product_name: 'CRM Tools' },
created_at: 1736033562,
description:
'Helps streamline customer relationship management by organizing contacts, automating follow-ups, and providing insights.',
id: 5,
name: 'CRM Assistant',
},
{
account_id: 4,
config: { product_name: 'SalesFlow' },
created_at: 1736033563,
description:
'Optimizes your sales pipeline by tracking prospects, forecasting sales, and automating administrative tasks.',
id: 6,
name: 'SalesBot',
},
{
account_id: 5,
config: { product_name: 'TicketMaster AI' },
created_at: 1736033564,
description:
'Automates ticket assignment, categorization, and customer query responses to enhance support efficiency.',
id: 7,
name: 'TicketBot',
},
{
account_id: 6,
config: { product_name: 'FinanceAssist' },
created_at: 1736033565,
description:
'Provides financial analytics, reporting, and insights, helping teams make data-driven financial decisions.',
id: 8,
name: 'Finance Wizard',
},
{
account_id: 8,
config: { product_name: 'HR Assistant' },
created_at: 1736033567,
description:
'Streamlines HR operations including employee management, payroll, and recruitment processes.',
id: 10,
name: 'HR Helper',
},
];
export const documentsList = [
{
account_id: 1,
assistant: { id: 1, name: 'Helper Pro' },
content:
'Comprehensive guide on using conversation filters to manage chats effectively.',
created_at: 1736143272,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688192-how-to-use-conversation-filters',
id: 3059,
name: 'How to use Conversation Filters? | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 2,
assistant: { id: 2, name: 'Support Genie' },
content:
'Step-by-step guide for automating ticket assignments and improving support workflow in Chatwoot.',
created_at: 1736143273,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688200-automating-ticket-assignments',
id: 3060,
name: 'Automating Ticket Assignments | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 3,
assistant: { id: 3, name: 'CRM Assistant' },
content:
'A detailed guide on managing and organizing customer profiles for better relationship management.',
created_at: 1736143274,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688210-managing-customer-profiles',
id: 3061,
name: 'Managing Customer Profiles | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 4,
assistant: { id: 4, name: 'SalesBot' },
content:
'Learn how to optimize sales tracking and improve your sales forecasting using advanced features.',
created_at: 1736143275,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688220-sales-tracking-guide',
id: 3062,
name: 'Sales Tracking Guide | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 5,
assistant: { id: 5, name: 'TicketBot' },
content:
'How to efficiently create, manage, and resolve tickets in Chatwoot.',
created_at: 1736143276,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688230-managing-tickets',
id: 3063,
name: 'Managing Tickets | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 6,
assistant: { id: 6, name: 'Finance Wizard' },
content:
'Detailed guide on how to use financial reporting tools and generate insightful analytics.',
created_at: 1736143277,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688240-financial-reporting',
id: 3064,
name: 'Financial Reporting | User Guide | Chatwoot',
status: 'available',
},
];
export const responsesList = [
{
account_id: 1,
answer:
'Messenger may be deactivated because you are on a free plan or the limit for inboxes might have been reached.',
created_at: 1736283330,
id: 87,
question: 'Why is my Messenger in Chatwoot deactivated?',
status: 'pending',
assistant: {
account_id: 1,
config: { product_name: 'Chatwoot' },
created_at: 1736033280,
description: 'Assists with general queries and system-wide issues.',
id: 1,
name: 'Assistant 2',
},
},
{
account_id: 2,
answer:
'You can integrate your WhatsApp account by navigating to the Integrations section and selecting the WhatsApp integration option.',
created_at: 1736283340,
id: 88,
question: 'How do I integrate WhatsApp with Chatwoot?',
assistant: {
account_id: 2,
config: { product_name: 'Chatwoot' },
created_at: 1736033281,
description: 'Helps with integration and setup-related inquiries.',
id: 2,
name: 'Assistant 3',
},
},
{
account_id: 3,
answer:
"To reset your password, go to the login page and click on 'Forgot Password', then follow the instructions sent to your email.",
created_at: 1736283350,
id: 89,
question: 'How can I reset my password in Chatwoot?',
assistant: {
account_id: 3,
config: { product_name: 'Chatwoot' },
created_at: 1736033282,
description: 'Handles account management and recovery support.',
id: 3,
name: 'Assistant 4',
},
},
{
account_id: 4,
answer:
"You can enable the dark mode in settings by navigating to 'Appearance' and selecting 'Dark Mode'.",
created_at: 1736283360,
id: 90,
question: 'How do I enable dark mode in Chatwoot?',
assistant: {
account_id: 4,
config: { product_name: 'Chatwoot' },
created_at: 1736033283,
description: 'Helps with UI and theme-related inquiries.',
id: 4,
name: 'Assistant 5',
},
},
{
account_id: 5,
answer:
"To add a new team member, navigate to 'Settings', then 'Team', and click on 'Add Team Member'.",
created_at: 1736283370,
id: 91,
question: 'How do I add a new team member in Chatwoot?',
assistant: {
account_id: 5,
config: { product_name: 'Chatwoot' },
created_at: 1736033284,
description: 'Supports team management and user access-related queries.',
id: 5,
name: 'Assistant 6',
},
},
{
account_id: 6,
answer:
"Campaigns in Chatwoot allow you to send targeted messages to specific user segments. You can create them in the 'Campaigns' section.",
created_at: 1736283380,
id: 92,
question: 'What are campaigns in Chatwoot?',
assistant: {
account_id: 6,
config: { product_name: 'Chatwoot' },
created_at: 1736033285,
description:
'Specialized in marketing, campaign management, and messaging strategies.',
id: 6,
name: 'Assistant 7',
},
},
];
export const inboxes = [
{
id: 7,
name: 'Email Support',
channel_type: INBOX_TYPES.EMAIL,
email: 'support@company.com',
},
{
id: 1,
name: 'Website Chat',
channel_type: INBOX_TYPES.WEB,
},
{
id: 2,
name: 'Facebook Support',
channel_type: INBOX_TYPES.FB,
},
{
id: 5,
name: 'SMS Service',
channel_type: INBOX_TYPES.TWILIO,
messaging_service_sid: 'MGxxxxxx',
},
{
id: 6,
name: 'WhatsApp Support',
channel_type: INBOX_TYPES.WHATSAPP,
phone_number: '+1987654321',
},
{
id: 8,
name: 'Telegram Support',
channel_type: INBOX_TYPES.TELEGRAM,
},
{
id: 9,
name: 'LINE Support',
channel_type: INBOX_TYPES.LINE,
},
{
id: 10,
name: 'API Channel',
channel_type: INBOX_TYPES.API,
},
{
id: 11,
name: 'SMS Basic',
channel_type: INBOX_TYPES.SMS,
phone_number: '+1555555555',
},
];
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConnectInboxForm from './ConnectInboxForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const connectForm = ref(null);
const i18nKey = 'CAPTAIN.INBOXES.CREATE';
const handleSubmit = async payload => {
try {
await store.dispatch('captainInboxes/create', payload);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="create"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<ConnectInboxForm
ref="connectForm"
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,115 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainInboxes/getUIFlags'),
inboxes: useMapGetter('inboxes/getInboxes'),
captainInboxes: useMapGetter('captainInboxes/getRecords'),
};
const initialState = {
inboxId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
inboxId: { required },
};
const inboxList = computed(() => {
const captainInboxIds = formState.captainInboxes.value.map(inbox => inbox.id);
return formState.inboxes.value
.filter(inbox => !captainInboxIds.includes(inbox.id))
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
});
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.INBOXES.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
inboxId: getErrorMessage('inboxId', 'INBOX'),
}));
const handleCancel = () => emit('cancel');
const prepareInboxPayload = () => ({
inboxId: state.inboxId,
assistantId: props.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareInboxPayload());
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.INBOXES.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxList"
:has-error="!!formErrors.inboxId"
:placeholder="t('CAPTAIN.INBOXES.FORM.INBOX.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"
:message="formErrors.inboxId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,84 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue';
const props = defineProps({
selectedResponse: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const responseForm = ref(null);
const updateResponse = responseDetails =>
store.dispatch('captainResponses/update', {
id: props.selectedResponse.id,
...responseDetails,
});
const i18nKey = computed(() => `CAPTAIN.RESPONSES.${props.type.toUpperCase()}`);
const createResponse = responseDetails =>
store.dispatch('captainResponses/create', responseDetails);
const handleSubmit = async updatedResponse => {
try {
if (props.type === 'edit') {
await updateResponse(updatedResponse);
} else {
await createResponse(updatedResponse);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.RESPONSES.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<ResponseForm
ref="responseForm"
:mode="type"
:response="selectedResponse"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { responseLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!responseLimits.value) return false;
const { consumed, totalCount } = responseLimits.value;
if (!consumed || !totalCount) return false;
return consumed / totalCount > 0.8;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.RESPONSES') }}
</Banner>
</template>
@@ -0,0 +1,161 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
response: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
question: '',
answer: '',
assistantId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.RESPONSES.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
question: state.question,
answer: state.answer,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareDocumentDetails());
};
const updateStateFromResponse = response => {
if (!response) return;
const { question, answer, assistant } = response;
Object.assign(state, {
question,
answer,
assistantId: assistant.id,
});
};
watch(
() => props.response,
newResponse => {
if (props.mode === 'edit' && newResponse) {
updateStateFromResponse(newResponse);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.question"
:label="t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
:message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'"
/>
<Editor
v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
:message="formErrors.answer"
:max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.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"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t(`CAPTAIN.FORM.${mode.toUpperCase()}`)"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Checkbox from './Checkbox.vue';
import { ref } from 'vue';
const defaultValue = ref(false);
const isChecked = ref(false);
const checkedValue = ref(true);
const indeterminateValue = ref(true);
</script>
<template>
<Story title="Components/Checkbox" :layout="{ type: 'grid', width: '250px' }">
<Variant title="States">
<div class="p-2 space-y-4">
<div class="flex items-center justify-between gap-4">
<span>Default:</span>
<Checkbox v-model="defaultValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Checked:</span>
<Checkbox v-model="checkedValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate:</span>
<Checkbox v-model="indeterminateValue" indeterminate />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate disabled:</span>
<Checkbox v-model="indeterminateValue" indeterminate disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled:</span>
<Checkbox v-model="defaultValue" disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled Checked:</span>
<Checkbox v-model="isChecked" disabled />
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,63 @@
<script setup>
defineProps({
indeterminate: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel('modelValue', {
type: Boolean,
default: false,
});
const handleChange = event => {
modelValue.value = event.target.checked;
emit('change', event);
};
</script>
<template>
<div class="relative w-4 h-4">
<input
:checked="modelValue"
:indeterminate="indeterminate"
type="checkbox"
:disabled="disabled"
class="peer absolute inset-0 z-10 h-4 w-4 disabled:opacity-50 appearance-none rounded border border-n-slate-6 ring-transparent transition-all duration-200 checked:border-n-brand checked:bg-n-brand dark:border-gray-600 dark:checked:border-n-brand indeterminate:border-n-brand indeterminate:bg-n-brand hover:enabled:bg-n-blue-border cursor-pointer"
@change="handleChange"
/>
<!-- Checkmark SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- Minus/Indeterminate SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-indeterminate:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 7L11 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>
@@ -13,34 +13,14 @@ const props = defineProps({
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
emptyState: { type: String, default: '' },
message: { type: String, default: '' },
hasError: { type: Boolean, default: false },
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search']);
@@ -54,6 +34,12 @@ const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
// For API search, don't filter options locally
if (props.useApiResults && search.value) {
return props.options;
}
// For local search, filter options based on search term
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm)
@@ -1,9 +1,14 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import { nextTick, ref, watch } from 'vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
supportAgent: {
@@ -22,15 +27,34 @@ const props = defineProps({
type: String,
required: true,
},
assistants: {
type: Array,
default: () => [],
},
activeAssistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage']);
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', opt.prompt);
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -40,6 +64,21 @@ const scrollToBottom = async () => {
}
};
const promptOptions = [
{
label: 'Summarize this conversation',
prompt: `Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent`,
},
{
label: 'Suggest an answer',
prompt: `Analyze the customers inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information.`,
},
{
label: 'Rate this conversation',
prompt: `Review the conversation to see how well it meets the customers needs. Share a rating out of 5 based on tone, clarity, and effectiveness.`,
},
];
watch(
[() => props.messages, () => props.isCaptainTyping],
() => {
@@ -50,7 +89,7 @@ watch(
</script>
<template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
<div class="flex flex-col h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
@@ -68,6 +107,45 @@ watch(
<CopilotLoader v-if="isCaptainTyping" />
</div>
<CopilotInput class="mx-3 mt-px mb-4" @send="sendMessage" />
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ prompt.label }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div>
</div>
</template>
@@ -1,8 +1,12 @@
<script setup>
import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { useTrack } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
@@ -18,6 +22,11 @@ const props = defineProps({
},
});
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
return formatter.formattedMessage;
});
const insertIntoRichEditor = computed(() => {
return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes(
props.conversationInboxType
@@ -30,6 +39,7 @@ const useCopilotResponse = () => {
} else {
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content);
}
useTrack(COPILOT_EVENTS.USE_CAPTAIN_RESPONSE);
};
</script>
@@ -43,9 +53,7 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<div class="break-words">
{{ message.content }}
</div>
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
<div class="flex flex-row mt-1">
<Button
:label="$t('CAPTAIN.COPILOT.USE')"
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const props = defineProps({
assistants: {
type: Array,
required: true,
},
activeAssistant: {
type: Object,
required: true,
},
});
const emit = defineEmits(['setAssistant']);
const { t } = useI18n();
const activeAssistantLabel = computed(() => {
return props.activeAssistant
? props.activeAssistant.name
: t('CAPTAIN.COPILOT.SELECT_ASSISTANT');
});
</script>
<template>
<div>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<Button
:label="activeAssistantLabel"
icon="i-woot-captain"
ghost
slate
xs
:class="{ 'bg-n-alpha-2': isOpen }"
@click="toggle"
/>
</template>
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="assistant in assistants"
:key="assistant.id"
class="!items-start !gap-1 flex-col cursor-pointer"
@click="() => emit('setAssistant', assistant)"
>
<template #label>
<div class="flex gap-1 justify-between w-full">
<div class="items-start flex gap-1 flex-col">
<span class="text-n-slate-12 text-sm">
{{ assistant.name }}
</span>
<span class="line-clamp-2 text-n-slate-11 text-xs">
{{ assistant.description }}
</span>
</div>
<div
v-if="assistant.id === activeAssistant?.id"
class="flex items-center justify-center flex-shrink-0 w-4 h-4 rounded-full bg-n-slate-12 dark:bg-n-slate-11"
>
<i
class="i-lucide-check text-white dark:text-n-slate-1 size-3"
/>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</div>
</template>
@@ -150,6 +150,6 @@ defineExpose({ open, close });
<style scoped>
dialog::backdrop {
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
</style>
@@ -19,7 +19,7 @@ const beforeClass = computed(() => {
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
// https://issues.chromium.org/issues/40835530
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
});
</script>
@@ -2,7 +2,10 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
import {
buildAttributesFilterTypes,
CONTACT_ATTRIBUTES,
} from './helper/filterHelper.js';
import countries from 'shared/constants/countries.js';
/**
@@ -59,7 +62,11 @@ export function useContactFilterContext() {
* @type {import('vue').ComputedRef<FilterType[]>}
*/
const customFilterTypes = computed(() =>
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
buildAttributesFilterTypes(
contactAttributes.value,
getOperatorTypes,
'contact'
)
);
/**
@@ -67,8 +74,8 @@ export function useContactFilterContext() {
*/
const filterTypes = computed(() => [
{
attributeKey: 'name',
value: 'name',
attributeKey: CONTACT_ATTRIBUTES.NAME,
value: CONTACT_ATTRIBUTES.NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
inputType: 'plainText',
@@ -77,8 +84,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'email',
value: 'email',
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
value: CONTACT_ATTRIBUTES.EMAIL,
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
inputType: 'plainText',
@@ -87,8 +94,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'phone_number',
value: 'phone_number',
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
inputType: 'plainText',
@@ -97,8 +104,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'identifier',
value: 'identifier',
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
value: CONTACT_ATTRIBUTES.IDENTIFIER,
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
inputType: 'plainText',
@@ -107,8 +114,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'country_code',
value: 'country_code',
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
@@ -118,8 +125,8 @@ export function useContactFilterContext() {
attributeModel: 'additional',
},
{
attributeKey: 'city',
value: 'city',
attributeKey: CONTACT_ATTRIBUTES.CITY,
value: CONTACT_ATTRIBUTES.CITY,
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
inputType: 'plainText',
@@ -128,8 +135,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
inputType: 'date',
@@ -138,8 +145,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
inputType: 'date',
@@ -148,8 +155,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeKey: CONTACT_ATTRIBUTES.REFERER,
value: CONTACT_ATTRIBUTES.REFERER,
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
inputType: 'plainText',
@@ -158,8 +165,8 @@ export function useContactFilterContext() {
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
value: 'blocked',
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
value: CONTACT_ATTRIBUTES.BLOCKED,
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
inputType: 'searchSelect',

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