Merge branch 'develop' into fix/conv-status

This commit is contained in:
Muhsin Keloth
2025-08-14 14:44:41 +05:30
committed by GitHub
1293 changed files with 48847 additions and 4710 deletions
+1 -2
View File
@@ -136,8 +136,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTL }"
class="flex flex-col w-full h-screen min-h-0"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainScenarios extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId, page = 1, searchKey } = {}) {
return axios.get(`${this.url}/${assistantId}/scenarios`, {
params: { page, searchKey },
});
}
show({ assistantId, id }) {
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
}
create({ assistantId, ...data } = {}) {
return axios.post(`${this.url}/${assistantId}/scenarios`, {
scenario: data,
});
}
update({ assistantId, id }, data = {}) {
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
scenario: data,
});
}
delete({ assistantId, id }) {
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
}
}
export default new CaptainScenarios();
@@ -0,0 +1,16 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainTools extends ApiClient {
constructor() {
super('captain/assistants/tools', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, {
params,
});
}
}
export default new CaptainTools();
@@ -0,0 +1,21 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
reauthorizeWhatsApp({ inboxId, ...params }) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, {
...params,
inbox_id: inboxId,
});
}
}
export default new WhatsappChannel();
@@ -21,6 +21,14 @@ class PortalsAPI extends ApiClient {
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
sendCnameInstructions(portalSlug, email) {
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
}
sslStatus(portalSlug) {
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
}
}
export default PortalsAPI;
+4
View File
@@ -28,6 +28,10 @@ class Inboxes extends CacheEnabledApiClient {
agent_bot: botId,
});
}
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
}
export default new Inboxes();
@@ -12,6 +12,7 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
expect(inboxesAPI).toHaveProperty('syncTemplates');
});
describe('API calls', () => {
@@ -40,5 +41,12 @@ describe('#InboxesAPI', () => {
inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
});
it('#syncTemplates', () => {
inboxesAPI.syncTemplates(2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/inboxes/2/sync_templates'
);
});
});
});
@@ -37,30 +37,6 @@ body {
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
}
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
});
</script>
@@ -0,0 +1,37 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,50 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
>
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</div>
</template>
@@ -0,0 +1,266 @@
<script setup>
import { reactive, computed, watch, ref } 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 ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
return templateParserRef.value?.v$?.$invalid === false || true;
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
// Extract template content - this should be the template message body
const templateContent = parserData?.renderedTemplate || '';
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
/>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
@@ -218,10 +218,13 @@ const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
watch(
() => props.contactData?.id,
id => {
if (id) prepareStateBasedOnProps();
},
{ immediate: true }
);
// Expose state to parent component for avatar upload
defineExpose({
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const lastActivityAt = computed(() => {
@@ -20,6 +20,7 @@ const props = defineProps({
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
@@ -98,6 +99,7 @@ watch(
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -1,6 +1,9 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -26,6 +29,20 @@ const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
@@ -33,7 +50,10 @@ watch(
}
);
const handleDialogConfirm = () => {
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>
@@ -1,9 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -12,10 +18,20 @@ const props = defineProps({
},
});
const emit = defineEmits(['confirm']);
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
};
defineExpose({ dialogRef });
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
:show-confirm-button="false"
@close="resetForm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
</div>
</Dialog>
</template>
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, helpers } from '@vuelidate/validators';
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
import { required, minLength, helpers, url } from '@vuelidate/validators';
import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -71,7 +71,7 @@ const rules = {
isValidSlug
),
},
homePageLink: { shouldBeUrl },
homePageLink: { url },
};
const v$ = useVuelidate(rules, state);
@@ -315,7 +315,9 @@ const handleAvatarDelete = () => {
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -11,11 +12,52 @@ const props = defineProps({
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<Button
v-else
:label="
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>
@@ -26,6 +26,8 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -85,7 +96,10 @@ const handleDeletePortal = () => {
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const hasSlaThreshold = computed(() => {
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
icon: isUnread.value ? 'mail' : 'mail-unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
]);
const messageClasses = computed(() => ({
@@ -153,7 +154,7 @@ onBeforeMount(contextMenuActions.close);
<template>
<div
role="button"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
@@ -232,7 +233,7 @@ onBeforeMount(contextMenuActions.close);
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm text-n-slate-10">
<span class="text-xs text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
@@ -15,6 +16,7 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (props.isModal) return '';
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
@@ -179,14 +188,18 @@ const toggle = () => {
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(contactInboxes);
const processedInboxes = processContactableInboxes(
currentContact.contactInboxes || []
);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...activeContact.value,
...currentContact,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
@@ -202,7 +215,7 @@ const handleClickOutside = () => {
};
const onModalBackdropClick = () => {
if (!props.isModal) return;
if (!viewInModal.value) return;
handleClickOutside();
};
@@ -231,7 +244,7 @@ useKeyboardEvents(keyboardEvents);
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
'z-50': showComposeNewConversation && !viewInModal,
}"
>
<slot
@@ -243,12 +256,12 @@ useKeyboardEvents(keyboardEvents);
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
isModal,
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -25,6 +25,7 @@ const props = defineProps({
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
@@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -170,7 +172,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -57,7 +57,7 @@ const removeAttachment = id => {
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[42rem] 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] 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 min-w-0"
>
<ContactSelector
:contacts="contacts"
@@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -1,23 +1,25 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -25,19 +27,7 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
const filteredTemplates = computed(() => {
@@ -84,10 +74,13 @@ const handleSendMessage = template => {
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
@@ -96,13 +89,13 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -111,12 +104,12 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@@ -0,0 +1,64 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
</div>
</div>
</template>
@@ -106,7 +106,7 @@ onMounted(() => {
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
@@ -138,7 +138,8 @@ onMounted(() => {
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
:title="key"
>
{{ key }}
</span>
@@ -36,10 +36,11 @@ const transformInbox = ({
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -33,10 +34,18 @@ const props = defineProps({
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
inbox: {
type: Object,
default: null,
},
iconName: {
type: String,
default: null,
},
hideOfflineStatus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['upload', 'delete']);
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
default: { bg: '#E8E8E8', text: '#60646C' },
};
const STATUS_CLASSES = {
const STATUS_CLASSES = computed(() => ({
online: 'bg-n-teal-10',
busy: 'bg-n-amber-10',
offline: 'bg-n-slate-10',
};
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
}));
const showDefaultAvatar = computed(() => !props.src && !props.name);
@@ -174,21 +183,31 @@ watch(
</script>
<template>
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
v-if="status"
v-if="status && STATUS_CLASSES[status]"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
/>
<div
v-if="inbox && !(status && STATUS_CLASSES[status])"
:style="badgeStyles"
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
>
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
</div>
</slot>
<!-- Delete Avatar Button -->
<div
v-if="src && allowUpload"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 ltr:-right-2 rtl:-left-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
@@ -232,31 +251,40 @@ watch(
<!-- Fallback Icon if no name or image -->
<Icon
v-else
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
:title="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
icon="i-lucide-user"
:style="iconStyles"
/>
</template>
<!-- Upload Overlay and Input -->
<div
v-if="allowUpload"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
<slot
v-if="allowUpload || $slots.overlay"
name="overlay"
:size="size"
:handle-upload="handleUploadAvatar"
:file-input-ref="fileInput"
:handle-image-upload="handleImageUpload"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
v-if="allowUpload"
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</slot>
</span>
</span>
</template>
@@ -15,8 +15,8 @@ const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
const onClick = (item, index) => {
emit('click', item, index);
};
</script>
@@ -24,22 +24,25 @@ const onClick = event => {
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<!-- Render as button for all except the last item -->
<button
v-if="index === 0"
v-if="index !== items.length - 1"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
@click="onClick(item, index)"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
</nav>
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -0,0 +1,21 @@
<script setup>
import AddNewRulesDialog from './AddNewRulesDialog.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesDialog"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-4 py-4 bg-n-background h-[200px]">
<AddNewRulesDialog
button-label="Add a guardrail"
placeholder="Type in another guardrail..."
confirm-label="Create"
cancel-label="Cancel"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
confirmLabel: {
type: String,
default: '',
},
cancelLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const [showPopover, togglePopover] = useToggle();
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="buttonLabel"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
>
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<div class="flex gap-2 justify-between">
<Button
:label="cancelLabel"
sm
link
slate
class="h-10 hover:!no-underline"
@click="onClickCancel"
/>
<Button :label="confirmLabel" sm @click="onClickAdd" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import AddNewRulesInput from './AddNewRulesInput.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesInput"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<AddNewRulesInput
placeholder="Type in another response guideline..."
label="Add and save (↵)"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,51 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
};
</script>
<template>
<div
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
>
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<Button
:label="label"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickAdd"
/>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { vOnClickOutside } from '@vueuse/components';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const [showPopover, togglePopover] = useToggle();
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const resetState = () => {
Object.assign(state, {
id: '',
title: '',
description: '',
instruction: '',
});
};
const onClickAdd = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
await emit('add', state);
resetState();
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.CREATE')"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="w-[31.25rem] absolute top-10 ltr:left-0 rtl:right-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 z-50"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
</h3>
<div class="flex flex-col gap-4">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER'
)
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER'
)
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CREATE')"
class="w-full"
@click="onClickAdd"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
allItems: {
type: Array,
required: true,
},
selectAllLabel: {
type: String,
default: '',
},
selectedCountLabel: {
type: String,
default: '',
},
deleteLabel: {
type: String,
default: 'Delete',
},
});
const emit = defineEmits(['bulkDelete']);
const modelValue = defineModel({
type: Set,
default: () => new Set(),
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
);
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
},
});
</script>
<template>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
<div v-else class="flex items-center gap-3">
<slot name="default-actions" />
</div>
</transition>
</template>
@@ -57,9 +57,10 @@ const menuItems = computed(() => [
},
]);
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
});
const handleAction = ({ action, value }) => {
toggleDropdown(false);
@@ -55,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -130,7 +134,7 @@ const handleDocumentableClick = () => {
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
@@ -0,0 +1,37 @@
<script setup>
import RuleCard from './RuleCard.vue';
const sampleRules = [
{ id: 1, content: 'Block sensitive personal information', selectable: true },
{ id: 2, content: 'Reject offensive language', selectable: true },
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
];
</script>
<template>
<Story
title="Captain/Assistant/RuleCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Selectable List">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard
v-for="rule in sampleRules"
:id="rule.id"
:key="rule.id"
:content="rule.content"
:selectable="rule.selectable"
@select="id => console.log('Selected rule', id)"
@edit="id => console.log('Edit', id)"
@delete="id => console.log('Delete', id)"
/>
</div>
</Variant>
<Variant title="Non-Selectable">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard id="4" content="Replies should be friendly and clear." />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,93 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
content: {
type: String,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const isEditing = ref(false);
const editedContent = ref(props.content);
// Local content to display to avoid flicker until parent prop updates on inline edit
const localContent = ref(props.content);
// Keeps localContent in sync when parent updates content prop
watch(
() => props.content,
newVal => {
localContent.value = newVal;
}
);
const startEdit = () => {
isEditing.value = true;
editedContent.value = props.content;
};
const saveEdit = () => {
isEditing.value = false;
// Update local content
localContent.value = editedContent.value;
emit('edit', { id: props.id, content: editedContent.value });
};
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<InlineInput
v-if="isEditing"
v-model="editedContent"
focus-on-mount
@keyup.enter="saveEdit"
/>
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
{{ localContent }}
</span>
<div class="flex items-center gap-2">
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
<span class="w-px h-4 bg-n-weak" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
@click="emit('delete', id)"
/>
</div>
</CardLayout>
</template>
@@ -0,0 +1,45 @@
<script setup>
import ScenariosCard from './ScenariosCard.vue';
const sampleScenarios = [
{
id: 1,
title: 'Refund Order',
description: 'User requests a refund for a recent purchase.',
instruction:
'Gather order details and reason for refund. Use [Order Search](tool://order_search) then submit with [Refund Payment](tool://refund_payment).',
tools: ['order_search', 'refund_payment'],
},
{
id: 2,
title: 'Bug Report',
description: 'Customer reports a bug in the mobile app.',
instruction:
'Ask for reproduction steps and environment. Check [Known Issues](tool://known_issues) then create ticket with [Create Bug Report](tool://bug_report_create).',
tools: ['known_issues', 'bug_report_create'],
},
];
</script>
<template>
<Story
title="Captain/Assistant/ScenariosCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div
v-for="scenario in sampleScenarios"
:key="scenario.id"
class="px-4 py-4 bg-n-background"
>
<ScenariosCard
:id="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,218 @@
<script setup>
import { computed, h, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
instruction: {
type: String,
required: true,
},
tools: {
type: Array,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'delete', 'update']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const [isEditing, toggleEditing] = useToggle();
const startEdit = () => {
Object.assign(state, {
id: props.id,
title: props.title,
description: props.description,
instruction: props.instruction,
tools: props.tools,
});
toggleEditing(true);
};
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const onClickUpdate = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
emit('update', { ...state });
toggleEditing(false);
};
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('p', {
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-4"
:class="{
'[&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4': !isEditing,
'[&>div]:ltr:!pr-10 [&>div]:rtl:!pl-10': isEditing,
}"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="selectable && !isEditing"
class="absolute top-[1.125rem] ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div v-if="!isEditing" class="flex flex-col w-full">
<div class="flex items-start justify-between w-full gap-2">
<div class="flex flex-col items-start">
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
<span class="text-sm text-n-slate-11 mt-2">
{{ description }}
</span>
</div>
<div class="flex items-center gap-2">
<!-- <Button label="Test" slate xs ghost class="!text-sm" />
<span class="w-px h-4 bg-n-weak" /> -->
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
<span class="w-px h-4 bg-n-weak" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
@click="emit('delete', id)"
/>
</div>
</div>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
<div v-else class="overflow-hidden flex flex-col gap-4 w-full">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER')
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER')
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
<div class="flex items-center gap-3">
<Button
faded
slate
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.CANCEL')"
@click="toggleEditing(false)"
/>
<Button
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.UPDATE')"
@click="onClickUpdate"
/>
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,46 @@
<script setup>
import SuggestedRules from './SuggestedRules.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const guidelinesExample = [
{
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
</script>
<template>
<Story
title="Captain/Assistant/SuggestedRules"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Suggested Rules List">
<div class="px-20 py-4 bg-n-background">
<SuggestedRules
title="Example response guidelines"
:items="guidelinesExample"
>
<template #default="{ item }">
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
<Button
label="Add this"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
/>
</template>
</SuggestedRules>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add', 'close']);
const { t } = useI18n();
const onAddClick = () => {
emit('add');
};
const onClickClose = () => {
emit('close');
};
</script>
<template>
<div
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
>
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
<div class="flex items-center gap-3">
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
<span class="h-3 w-px bg-n-weak" />
<Button
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onAddClick"
/>
</div>
<Button
ghost
xs
slate
icon="i-lucide-x"
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickClose"
/>
</div>
<div
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
>
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
<slot :item="item" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import ToolsDropdown from './ToolsDropdown.vue';
const items = [
{
id: 'order_search',
title: 'Order Search',
description: 'Lookup orders by customer ID, email, or order number',
},
{
id: 'refund_payment',
title: 'Refund Payment',
description: 'Initiates a refund on a specific payment',
},
{
id: 'fetch_customer',
title: 'Fetch Customer',
description: 'Pulls customer details (email, tags, last seen, etc.)',
},
];
const selectedIndex = ref(0);
</script>
<template>
<Story
title="Captain/Assistant/ToolsDropdown"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="relative h-80 bg-n-background p-4">
<ToolsDropdown :items="items" :selected-index="selectedIndex" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { ref, watch, nextTick } from 'vue';
const props = defineProps({
items: {
type: Array,
required: true,
},
selectedIndex: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const toolsDropdownRef = ref(null);
const onItemClick = idx => emit('select', idx);
watch(
() => props.selectedIndex,
() => {
nextTick(() => {
const el = toolsDropdownRef.value?.querySelector(
`#tool-item-${props.selectedIndex}`
);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
},
{ immediate: true }
);
</script>
<template>
<div
ref="toolsDropdownRef"
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
>
<div
v-for="(tool, idx) in items"
:id="`tool-item-${idx}`"
:key="tool.id || idx"
:class="{ 'bg-n-alpha-black2': idx === selectedIndex }"
class="flex flex-col gap-1 rounded-md py-2 px-2 cursor-pointer hover:bg-n-alpha-black2"
@click="onItemClick(idx)"
>
<span class="text-n-slate-12 font-medium text-sm">{{ tool.title }}</span>
<span class="text-n-slate-11 text-sm">{{ tool.description }}</span>
</div>
</div>
</template>
@@ -35,6 +35,7 @@ const initialState = {
productName: '',
featureFaq: false,
featureMemory: false,
featureCitation: false,
};
const state = reactive({ ...initialState });
@@ -70,6 +71,7 @@ const prepareAssistantDetails = () => ({
product_name: state.productName,
feature_faq: state.featureFaq,
feature_memory: state.featureMemory,
feature_citation: state.featureCitation,
},
});
@@ -93,6 +95,7 @@ const updateStateFromAssistant = assistant => {
productName: config.product_name,
featureFaq: config.feature_faq || false,
featureMemory: config.feature_memory || false,
featureCitation: config.feature_citation || false,
});
};
@@ -151,6 +154,13 @@ watch(
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.featureCitation" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</span>
</label>
</fieldset>
<div class="flex items-center justify-between w-full gap-3">
@@ -41,6 +41,7 @@ const initialState = {
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
@@ -152,6 +154,7 @@ const handleFeaturesUpdate = () => {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
@@ -300,20 +303,19 @@ watch(
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
@@ -0,0 +1,150 @@
<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 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({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
name: '',
description: '',
productName: '',
features: {
conversationFaqs: false,
memories: false,
citations: 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 getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<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'"
/>
<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'"
/>
<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'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input v-model="state.features.conversationFaqs" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleBasicInfoUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
controlItem: {
type: Object,
default: () => ({}),
},
});
const router = useRouter();
const onClick = name => {
router.push({ name });
};
</script>
<template>
<div
:key="controlItem.name"
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
@click="onClick(controlItem.routeName)"
>
<div class="flex items-center justify-between w-full gap-1 h-8">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ controlItem.name }}
</span>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-chevron-right"
slate
ghost
xs
@click="onClick(controlItem.routeName)"
/>
</div>
</div>
<span class="text-n-slate-11 text-sm leading-[21px] line-clamp-5">
{{ controlItem.description }}
</span>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
handoffMessage: '',
resolutionMessage: '',
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import SettingsHeader from './SettingsHeader.vue';
</script>
<template>
<Story
title="Captain/PageComponents/SettingsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="General Settings"
description="Configure general preferences for your workspace."
/>
</div>
</Variant>
<Variant title="Long Description">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="Integrations"
description="Manage and configure third-party integrations such as Slack, Zapier, and Webhooks to enhance your workflow."
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,19 @@
<script setup>
defineProps({
heading: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<header class="flex flex-col items-start gap-2">
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
<p class="text-n-slate-11 text-sm">{{ description }}</p>
</header>
</template>
@@ -6,7 +6,7 @@ import DropdownBody from './base/DropdownBody.vue';
import DropdownSection from './base/DropdownSection.vue';
import DropdownItem from './base/DropdownItem.vue';
import DropdownSeparator from './base/DropdownSeparator.vue';
import WootSwitch from 'components/ui/Switch.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
const currentUserAutoOffline = ref(false);
@@ -61,7 +61,7 @@ const menuItems = ref([
<DropdownItem label="Contact Support" class="justify-between">
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
<div class="flex-shrink-0">
<WootSwitch v-model="currentUserAutoOffline" />
<ToggleSwitch v-model="currentUserAutoOffline" />
</div>
</DropdownItem>
</DropdownSection>
@@ -2,6 +2,7 @@
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
@@ -178,11 +179,11 @@ defineExpose({ validate });
disable-search
:options="booleanOptions"
/>
<input
<Input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
@@ -9,6 +9,7 @@ import { useContactFilterContext } from './contactProvider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -109,16 +110,13 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isSegmentView">
<label class="pb-6 border-b border-n-weak">
<div class="mb-2 text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
</div>
<input
<div class="pb-6 border-b border-n-weak">
<Input
v-model="segmentNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:label="$t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL')"
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
/>
</label>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -9,6 +9,7 @@ import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -110,16 +111,13 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<label class="border-b border-n-weak pb-6">
<div class="text-n-slate-11 text-sm mb-2">
{{ t('FILTER.FOLDER_LABEL') }}
</div>
<input
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</label>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -6,10 +6,12 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { vOnClickOutside } from '@vueuse/components';
import { useTrack } from 'dashboard/composables';
import NextButton from 'next/button/Button.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
NextButton,
NextInput,
},
directives: {
onClickOutside: vOnClickOutside,
@@ -103,20 +105,13 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<label :class="{ error: v$.name.$error }">
<input
v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span
v-if="v$.name.$error"
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
>
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span>
</label>
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
@blur="v$.name.$touch"
/>
<div class="flex flex-row justify-end w-full gap-2">
<NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
@@ -1,4 +1,5 @@
<script setup>
import { toRef } from 'vue';
import { useChannelIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
@@ -9,7 +10,7 @@ const props = defineProps({
},
});
const channelIcon = useChannelIcon(props.inbox);
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
</script>
<template>
@@ -22,15 +22,21 @@ export function useChannelIcon(inbox) {
};
const channelIcon = computed(() => {
const type = inbox.channel_type;
const inboxDetails = inbox.value || inbox;
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && inbox.provider) {
if (Object.keys(providerIconMap).includes(inbox.provider)) {
icon = providerIconMap[inbox.provider];
if (type === 'Channel::Email' && inboxDetails.provider) {
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider];
}
}
// Special case for Twilio whatsapp
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
icon = 'i-ri-whatsapp-fill';
}
return icon ?? 'i-ri-global-fill';
});
@@ -25,6 +25,77 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-phone-fill');
});
it('returns correct icon for Line channel', () => {
const inbox = { channel_type: 'Channel::Line' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-line-fill');
});
it('returns correct icon for SMS channel', () => {
const inbox = { channel_type: 'Channel::Sms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns correct icon for Telegram channel', () => {
const inbox = { channel_type: 'Channel::Telegram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-telegram-fill');
});
it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-twitter-x-fill');
});
it('returns correct icon for WebWidget channel', () => {
const inbox = { channel_type: 'Channel::WebWidget' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-global-fill');
});
it('returns correct icon for Instagram channel', () => {
const inbox = { channel_type: 'Channel::Instagram' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-instagram-fill');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'whatsapp',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
it('returns chat icon for Twilio SMS with undefined medium', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
medium: undefined,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-chat-1-fill');
});
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -119,7 +119,13 @@ const handleSeeOriginal = () => {
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
:class="{
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
isIncoming,
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
isOutgoing,
}"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle']);
const route = useRoute();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const toggleSidebar = () => {
emit('toggle');
};
</script>
<template>
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
},
]"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -17,10 +18,18 @@ import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
@@ -77,6 +86,11 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -331,6 +345,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'WhatsApp',
label: t('SIDEBAR.WHATSAPP'),
to: accountScopedRoute('campaigns_whatsapp_index'),
},
],
},
{
@@ -483,7 +502,17 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
},
]"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -218,8 +218,8 @@ onMounted(async () => {
left: 0;
}
.app-rtl--wrapper .sidebar-group-children > .child-item:last-child::after,
.app-rtl--wrapper
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
#app[dir='rtl']
.sidebar-group-children
> *:last-child
> *:last-child
@@ -14,6 +14,7 @@ import {
} from 'next/dropdown-menu/base';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
const { t } = useI18n();
const store = useStore();
@@ -48,6 +49,16 @@ const activeStatus = computed(() => {
return availabilityStatuses.value.find(status => status.active);
});
const autoOfflineToggle = computed({
get: () => currentUserAutoOffline.value,
set: autoOffline => {
store.dispatch('updateAutoOffline', {
accountId: currentAccountId.value,
autoOffline,
});
},
});
function changeAvailabilityStatus(availability) {
if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
@@ -62,13 +73,6 @@ function changeAvailabilityStatus(availability) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR'));
}
}
function updateAutoOffline(autoOffline) {
store.dispatch('updateAutoOffline', {
accountId: currentAccountId.value,
autoOffline,
});
}
</script>
<template>
@@ -118,11 +122,7 @@ function updateAutoOffline(autoOffline) {
class="size-4 text-n-slate-10"
/>
</div>
<woot-switch
class="flex-shrink-0"
:model-value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
<ToggleSwitch v-model="autoOfflineToggle" />
</DropdownItem>
</div>
</DropdownSection>
@@ -19,8 +19,8 @@ const updateValue = () => {
<template>
<button
type="button"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2"
:class="modelValue ? 'bg-n-brand' : 'bg-n-alpha-1 dark:bg-n-alpha-2'"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
role="switch"
:aria-checked="modelValue"
@click="updateValue"
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
@@ -0,0 +1,279 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
allKeysRequired,
replaceTemplateVariables,
DEFAULT_LANGUAGE,
DEFAULT_CATEGORY,
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.components || !Array.isArray(value.components)) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
});
const categoryLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
});
const headerComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
});
const bodyComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
});
const bodyText = computed(() => {
return bodyComponent.value?.text || '';
});
const hasMediaHeader = computed(() =>
MEDIA_FORMATS.includes(headerComponent.value?.format)
);
const formatType = computed(() => {
const format = headerComponent.value?.format;
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(hasVariables),
allKeysRequired,
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = buildTemplateParameters(
props.template,
hasMediaHeader.value
);
};
const updateMediaUrl = value => {
processedParams.value.header ??= {};
processedParams.value.header.media_url = value;
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const { name, category, language, namespace } = props.template;
const payload = {
message: renderedTemplate.value,
templateParams: {
name,
category,
language,
namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaHeader">
<div v-if="hasMediaHeader" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
type: formatType,
}) || `${formatType} Header`
}}
</p>
<div class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_url || ''"
type="url"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
type: formatType,
})
"
@update:model-value="updateMediaUrl"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="processedParams.body">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams.body"
:key="`body-${key}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.body[key]"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
<!-- Button Variables Section -->
<div v-if="processedParams.buttons">
<p class="mb-2.5 text-sm font-semibold">
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
</p>
<div
v-for="(button, index) in processedParams.buttons"
:key="`button-${index}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.buttons[index].parameter"
type="text"
class="flex-1"
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
/>
</div>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -98,7 +98,7 @@ const toggleConversationLayout = () => {
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -124,7 +124,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
@@ -150,7 +150,7 @@ const toggleConversationLayout = () => {
/>
<div
id="conversationFilterTeleportTarget"
class="absolute z-40 mt-2"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
+9 -2
View File
@@ -61,7 +61,9 @@ const onCopy = async e => {
<template>
<div class="relative text-left">
<div class="top-1.5 absolute right-1.5 flex items-center gap-1">
<div
class="top-1.5 absolute ltr:right-1.5 rtl:left-1.5 flex backdrop-blur-sm rounded-lg items-center gap-1"
>
<form
v-if="enableCodePen"
class="flex items-center"
@@ -86,6 +88,11 @@ const onCopy = async e => {
@click="onCopy"
/>
</div>
<highlightjs v-if="script" :language="lang" :code="scrubbedScript" />
<highlightjs
v-if="script"
:language="lang"
:code="scrubbedScript"
class="[&_code]:text-start"
/>
</div>
</template>
@@ -1,94 +0,0 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@primary-action="routeToBilling"
/>
</template>
@@ -4,7 +4,12 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import wootConstants from 'dashboard/constants/globals';
defineProps({
conversationInboxType: {
type: String,
@@ -13,12 +18,20 @@ defineProps({
});
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const currentChat = useMapGetter('getSelectedChat');
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
@@ -32,7 +45,6 @@ const isFeatureEnabledonAccount = useMapGetter(
);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,6 +67,15 @@ const activeAssistant = computed(() => {
return assistants.value[0];
});
const closeCopilotPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_copilot_panel_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
@@ -63,6 +84,9 @@ const setAssistant = async assistant => {
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
@@ -94,14 +118,23 @@ const sendMessage = async message => {
};
onMounted(() => {
store.dispatch('captainAssistants/get');
if (isEnterprise) {
store.dispatch('captainAssistants/get');
}
});
</script>
<template>
<div
v-if="shouldShowCopilotPanel"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
'md:hidden': !shouldShowCopilotPanel,
},
]"
>
<Copilot
:messages="messages"
@@ -1,6 +1,5 @@
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
/* eslint no-plusplus: 0 */
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
import Code from './Code.vue';
import ColorPicker from './widgets/ColorPicker.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
@@ -18,11 +17,9 @@ import Modal from './Modal.vue';
import Spinner from 'shared/components/Spinner.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
Code,
ColorPicker,
ConfirmDeleteModal,
@@ -40,7 +37,6 @@ const WootUIKit = {
Spinner,
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
@@ -1,83 +0,0 @@
<script>
export default {
props: {
modelValue: { type: Boolean, default: false },
size: { type: String, default: '' },
},
emits: ['update:modelValue', 'input'],
methods: {
onClick() {
this.$emit('update:modelValue', !this.modelValue);
this.$emit('input', !this.modelValue);
},
},
};
</script>
<template>
<button
type="button"
class="toggle-button p-0"
:class="{ active: modelValue, small: size === 'small' }"
role="switch"
:aria-checked="modelValue.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: modelValue }" />
</button>
</template>
<style lang="scss" scoped>
.toggle-button {
@apply bg-n-slate-5;
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
border-radius: 0.5625rem;
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 1.188rem;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 2.125rem;
&.active {
@apply bg-n-brand;
}
&.small {
width: 1.375rem;
height: 0.875rem;
span {
@apply size-2.5;
&.active {
@apply ltr:translate-x-[0.5rem] ltr:translate-y-0 rtl:translate-x-[-0.5rem] rtl:translate-y-0;
}
}
}
span {
@apply bg-n-background;
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: 0.9375rem;
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 0.9375rem;
&.active {
@apply ltr:translate-x-[0.9375rem] ltr:translate-y-0 rtl:translate-x-[-0.9375rem] rtl:translate-y-0;
}
}
}
</style>
@@ -1,9 +1,5 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -133,7 +133,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
@@ -152,7 +156,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
@@ -1,55 +0,0 @@
<script>
export default {
name: 'Avatar',
props: {
username: {
type: String,
default: '',
},
size: {
type: Number,
default: 40,
},
},
computed: {
style() {
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
},
userInitial() {
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
};
</script>
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
<slot>{{ userInitial }}</slot>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.avatar-color {
background-image: linear-gradient(to top, #c2e1ff 0%, #d6ebff 100%);
}
.dark-avatar-color {
background-image: linear-gradient(to top, #135899 0%, #135899 100%);
}
}
.avatar-container {
@apply flex leading-[100%] font-medium items-center justify-center text-center cursor-default avatar-color dark:dark-avatar-color text-n-blue-text;
}
</style>
@@ -203,7 +203,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -211,7 +211,11 @@ export default {
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
@@ -221,7 +225,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
@@ -229,7 +233,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
@@ -1,32 +1,22 @@
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
defineProps({
inbox: {
type: Object,
default: () => {},
},
computed: {
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
});
</script>
<template>
<div
class="inbox--name inline-flex items-center py-0.5 px-0 leading-3 whitespace-nowrap bg-none text-n-slate-11 text-xs my-0 mx-2.5"
>
<fluent-icon
class="mr-0.5 rtl:ml-0.5 rtl:mr-0"
:icon="computedInboxClass"
size="12"
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
/>
{{ inbox.name }}
<span class="truncate">
{{ inbox.name }}
</span>
</div>
</template>
@@ -1,48 +0,0 @@
import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: false,
};
},
});
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(false);
});
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: true,
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.isVisible()).toBe(true);
});
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('div').text()).toBe('AR');
});
});
@@ -1,224 +0,0 @@
<script>
/**
* Thumbnail Component
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - Username for avatar
*/
import Avatar from './Avatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
export default {
components: {
Avatar,
},
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '40px',
},
badge: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
hasBorder: {
type: Boolean,
default: false,
},
shouldShowStatusAlways: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
},
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
computed: {
userNameWithoutEmoji() {
return removeEmoji(this.username);
},
showStatusIndicator() {
if (this.shouldShowStatusAlways) return true;
return this.status === 'online' || this.status === 'busy';
},
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
const borderRadius = `${size / 2}px`;
return { width: badgeSize, height: badgeSize, borderRadius };
},
statusStyle() {
const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize };
},
thumbnailClass() {
const className = this.hasBorder
? 'border border-solid border-white dark:border-n-weak'
: '';
const variant =
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${className} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
<template>
<div
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<slot>
<img
v-show="shouldShowImage"
:src="src"
draggable="false"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
/>
</slot>
<img
v-if="badgeSrc"
class="source-badge z-20"
:style="badgeStyle"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
class="z-20"
:class="`source-badge user-online-status user-online-status--${status}`"
:style="statusStyle"
/>
</div>
</template>
<style lang="scss" scoped>
.user-thumbnail-box {
flex: 0 0 auto;
max-width: 100%;
position: relative;
&.is-rounded {
border-radius: 50%;
}
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
border-radius: 0.5625rem;
}
height: 100%;
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
}
.source-badge {
border-radius: 0.1875rem;
bottom: -0.125rem;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
position: absolute;
right: 0;
@apply bg-n-background p-0.5 size-3;
}
.user-online-status {
@apply bottom-0.5 rounded-full;
&:after {
content: ' ';
}
}
.user-online-status--online {
@apply bg-n-teal-10;
}
.user-online-status--busy {
@apply bg-n-amber-10;
}
.user-online-status--offline {
@apply bg-n-slate-10;
}
}
</style>
@@ -1,78 +1,69 @@
<script>
import Thumbnail from './Thumbnail.vue';
<script setup>
import { computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
export default {
components: {
Thumbnail,
const props = defineProps({
usersList: {
type: Array,
default: () => [],
},
props: {
usersList: {
type: Array,
default: () => [],
},
size: {
type: String,
default: '24px',
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
size: {
type: Number,
default: 24,
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', 'tight'].includes(value);
},
},
};
});
const gapClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
}
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const moreThumbnailsClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
return 'ltr:-ml-1 rtl:-mr-1';
});
</script>
<template>
<div class="overlapping-thumbnails">
<Thumbnail
<div class="flex">
<Avatar
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:username="user.name"
has-border
:name="user.name"
:size="size"
:class="`overlapping-thumbnail gap-${gap}`"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
:class="gapClass"
rounded-full
/>
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
<span
v-if="showMoreThumbnailsCount"
class="text-n-slate-11 bg-n-slate-4 outline outline-1 outline-n-background text-xs font-medium rounded-full px-2 inline-flex items-center shadow relative"
:class="moreThumbnailsClass"
>
{{ moreThumbnailsText }}
</span>
</div>
</template>
<style lang="scss" scoped>
.overlapping-thumbnails {
display: flex;
}
.overlapping-thumbnail {
position: relative;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:not(:first-child) {
margin-left: -0.25rem;
}
.gap-tight {
margin-left: -0.5rem;
}
}
.thumbnail-more-text {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
@apply text-n-slate-11 bg-n-slate-4 border border-n-weak text-xs font-medium rounded-full px-2 ltr:-ml-2 rtl:-mr-2 inline-flex items-center relative;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
defineProps({
user: {
@@ -7,8 +7,8 @@ defineProps({
default: () => ({}),
},
size: {
type: String,
default: '20px',
type: Number,
default: 20,
},
textClass: {
type: String,
@@ -19,11 +19,13 @@ defineProps({
<template>
<div class="flex items-center gap-1.5 text-left">
<Thumbnail
<Avatar
:src="user.thumbnail"
:size="size"
:username="user.name"
:name="user.name"
:status="user.availability_status"
hide-offline-status
rounded-full
/>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
@@ -15,6 +15,7 @@ import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -72,6 +73,7 @@ const props = defineProps({
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
@@ -89,6 +91,7 @@ const emit = defineEmits([
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'toggleToolsMenu',
'clearSelection',
'blur',
'focus',
@@ -140,7 +143,9 @@ const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
@@ -216,11 +221,17 @@ const plugins = computed(() => {
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showToolsMenu,
searchTerm: toolSearchKey,
isAllowed: () => props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate,
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '/',
@@ -262,6 +273,9 @@ watch(showCannedMenu, updatedValue => {
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
watch(showToolsMenu, updatedValue => {
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
@@ -538,6 +552,7 @@ function insertSpecialContent(type, content) {
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
};
useTrack(event_map[type]);
@@ -699,6 +714,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<TagTools
v-if="showToolsMenu"
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<input
ref="imageUpload"
type="file"
@@ -812,6 +832,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
}
.prosemirror-tools-node {
@apply font-medium text-n-slate-12 py-0;
}
.editor-wrap {
@apply mb-4;
}
@@ -94,7 +94,7 @@ export default {
type: Boolean,
default: true,
},
hasWhatsappTemplates: {
enableWhatsAppTemplates: {
type: Boolean,
default: false,
},
@@ -333,7 +333,7 @@ export default {
@click="toggleMessageSignature"
/>
<NextButton
v-if="hasWhatsappTemplates"
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
icon="i-ph-whatsapp-logo"
slate
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Thumbnail from '../Thumbnail.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,247 +14,227 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
assignee() {
return this.chatMetadata.assignee || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const router = useRouter();
const store = useStore();
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
unreadCount() {
return this.chat.unread_count;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
hasUnread() {
return this.unreadCount > 0;
},
const chatMetadata = computed(() => props.chat.meta || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const senderId = computed(() => chatMetadata.value.sender?.id);
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const currentContact = computed(() => {
return senderId.value
? store.getters['contacts/getContact'](senderId.value)
: {};
});
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
if (this.isActiveChat) {
return;
}
const unreadCount = computed(() => props.chat.unread_count);
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
const hasUnread = computed(() => unreadCount.value > 0);
const isInboxNameVisible = computed(() => !activeInbox.value);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inboxId = computed(() => props.chat.inbox_id);
const inbox = computed(() => {
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -264,46 +244,65 @@ export default {
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<label
v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
<Thumbnail
<Avatar
v-if="!hideThumbnail"
:name="currentContact.name"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:size="32"
:status="currentContact.availability_status"
size="32px"
/>
:inbox="inbox"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
hide-offline-status
rounded-full
>
<template #overlay="{ size }">
<label
v-if="hovered || selected"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="flex justify-between conversation-card--meta">
<InboxName v-if="showInboxName" :inbox="inbox" />
<div class="flex gap-2 ml-2 rtl:mr-2 rtl:ml-0">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex text-ellipsis overflow-hidden whitespace-nowrap"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" />
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -311,24 +310,27 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -336,12 +338,17 @@ export default {
/>
</span>
<span
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -359,6 +366,8 @@ export default {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@@ -367,59 +376,8 @@ export default {
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -6,7 +6,7 @@ import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
@@ -105,12 +105,13 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:back-url="backButtonUrl"
class="ltr:mr-2 rtl:ml-2"
/>
<Thumbnail
<Avatar
:name="currentContact.name"
:src="currentContact.thumbnail"
:username="currentContact.name"
:size="32"
:status="currentContact.availability_status"
size="32px"
class="flex-shrink-0"
hide-offline-status
rounded-full
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
@@ -2,6 +2,9 @@
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
defineProps({
currentChat: {
@@ -10,7 +13,8 @@ defineProps({
},
});
const { uiSettings } = useUISettings();
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
@@ -20,11 +24,31 @@ const activeTab = computed(() => {
}
return null;
});
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const closeContactPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
v-on-click-outside="() => closeContactPanel()"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
'md:hidden': activeTab !== 0,
},
]"
>
<div class="flex flex-1 overflow-auto">
<ContactPanel
@@ -184,9 +184,8 @@ export default {
return false;
},
hasWhatsappTemplates() {
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.length;
showWhatsappTemplates() {
return this.isAWhatsAppCloudChannel && !this.isPrivate;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
@@ -373,6 +372,7 @@ export default {
const variables = getMessageVariables({
conversation: this.currentChat,
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
},
@@ -1212,7 +1212,7 @@ export default {
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
:enable-whats-app-templates="showWhatsappTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
@@ -0,0 +1,56 @@
<script setup>
import { ref, computed, watch } from 'vue';
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMapGetter } from 'dashboard/composables/store.js';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectTool']);
const tools = useMapGetter('captainTools/getRecords');
const selectedIndex = ref(0);
const filteredTools = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
});
const adjustScroll = () => {};
const onSelect = idx => {
if (idx) selectedIndex.value = idx;
emit('selectTool', filteredTools.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: filteredTools,
onSelect,
adjustScroll,
selectedIndex,
});
watch(filteredTools, newListOfTools => {
if (newListOfTools.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
</script>
<template>
<ToolsDropdown
v-if="filteredTools.length"
:items="filteredTools"
:selected-index="selectedIndex"
class="bottom-20"
@select="onSelect"
/>
<template v-else />
</template>
@@ -1,4 +1,4 @@
<script>
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
@@ -8,158 +8,51 @@
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
defineProps({
template: {
type: Object,
default: () => ({}),
},
props: {
template: {
type: Object,
default: () => ({}),
},
},
emits: ['sendMessage', 'resetTemplate'],
setup(props, { emit }) {
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
});
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const processedParams = ref({});
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const templateString = computed(() => {
return props.template.components.find(
component => component.type === 'BODY'
).text;
});
const variables = computed(() => {
return templateString.value.match(/{{([^}]+)}}/g);
});
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(variables),
allKeysRequired,
},
},
{ processedParams }
);
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const resetTemplate = () => {
emit('resetTemplate');
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(generateVariables);
return {
processedParams,
variables,
templateString,
processedString,
v$,
resetTemplate,
sendMessage,
};
},
const handleResetTemplate = () => {
emit('resetTemplate');
};
</script>
<template>
<div class="w-full">
<textarea
v-model="processedString"
rows="4"
readonly
class="template-input"
/>
<div v-if="variables" class="p-2.5">
<p class="text-sm font-semibold mb-2.5">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="items-center flex mb-2.5"
>
<span
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
>
{{ key }}
</span>
<woot-input
v-model="processedParams[key]"
type="text"
class="flex-1 text-sm ml-2.5"
:styles="{ marginBottom: 0 }"
/>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<footer class="flex justify-end gap-2">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer>
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
:disabled="disabled"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
</div>
</template>
@@ -1,90 +1,184 @@
<script>
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
<script setup>
import { ref, computed, toRef } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import {
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
export default {
props: {
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['onSelect'],
data() {
return {
query: '',
};
},
computed: {
whatsAppTemplateMessages() {
// TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
template.name.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
},
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const whatsAppTemplateMessages = useFunctionGetter(
'inboxes/getFilteredWhatsAppTemplates',
toRef(props, 'inboxId')
);
const filteredTemplateMessages = computed(() =>
whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const getTemplateBody = template => {
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
};
const getTemplateHeader = template => {
return findComponentByType(template, COMPONENT_TYPES.HEADER);
};
const getTemplateFooter = template => {
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
};
const getTemplateButtons = template => {
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
};
const hasMediaContent = template => {
const header = getTemplateHeader(template);
return header && MEDIA_FORMATS.includes(header.format);
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
<template>
<div class="w-full">
<div
class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
<div class="flex gap-2 mb-2.5">
<div
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input
v-model="query"
type="search"
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
icon="i-lucide-refresh-ccw"
class="text-n-slate-12 size-4"
:class="{ 'animate-spin': isRefreshing }"
/>
</button>
</div>
<div
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="$emit('onSelect', template)"
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="emit('onSelect', template)"
>
<div>
<div class="flex items-center justify-between mb-2.5">
<div class="flex justify-between items-center mb-2.5">
<p class="text-sm">
{{ template.name }}
</p>
<span
class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default text-n-slate-12"
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ template.language }}
</span>
</div>
<div>
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) ||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
}}
</div>
</div>
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p>{{ template.category }}</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
</div>
</div>
</button>
@@ -94,11 +188,18 @@ export default {
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div>
<div v-if="!filteredTemplateMessages.length">
<p>
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p>
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
</div>
</div>
@@ -10,7 +10,7 @@ import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
@@ -186,10 +186,12 @@ onMounted(() => {
v-if="senderDetails"
class="flex items-center min-w-[15rem] shrink-0"
>
<Thumbnail
<Avatar
v-if="senderDetails.avatar"
:username="senderDetails.name"
:name="senderDetails.name"
:src="senderDetails.avatar"
:size="40"
rounded-full
class="flex-shrink-0"
/>
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
@@ -1,5 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default {
components: {
@@ -37,6 +53,14 @@ export default {
type: String,
default: null,
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
@@ -47,6 +71,7 @@ export default {
'assignTeam',
'assignLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
@@ -56,6 +81,7 @@ export default {
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -63,7 +89,7 @@ export default {
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
icon: 'mail-unread',
},
statusMenuConfig: [
{
@@ -88,7 +114,7 @@ export default {
icon: 'snooze',
},
priorityConfig: {
key: 'priority',
key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
@@ -115,25 +141,35 @@ export default {
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: 'label',
key: MENU.LABEL,
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: 'agent',
key: MENU.AGENT,
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: 'team',
key: MENU.TEAM,
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
key: MENU.DELETE,
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
@@ -180,6 +216,10 @@ export default {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
@@ -194,6 +234,24 @@ export default {
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -217,83 +275,114 @@ export default {
</script>
<template>
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig">
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<MenuItem
v-if="show(option.key)"
:key="option.key"
:option="option"
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<MenuItem
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig">
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
@@ -1,20 +1,16 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
option: {
type: Object,
default: () => {},
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
variant: {
type: String,
default: 'default',
},
};
});
</script>
<template>
@@ -30,12 +26,12 @@ export default {
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Thumbnail
<Avatar
v-if="variant === 'agent'"
:username="option.label"
:name="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">

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