Merge branch 'feat/saml-controllers' into feat/saml-ui
This commit is contained in:
+28
-6
@@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -32,6 +34,7 @@ const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -63,6 +66,20 @@ const sendWithSignature = computed(() => {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
});
|
||||
|
||||
const showTwilioContentTemplates = computed(() => {
|
||||
return props.isTwilioWhatsAppInbox && props.inboxId;
|
||||
});
|
||||
|
||||
const shouldShowEmojiButton = computed(() => {
|
||||
return (
|
||||
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
@@ -125,7 +142,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -136,7 +153,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
v-if="showTwilioContentTemplates"
|
||||
:inbox-id="inboxId"
|
||||
@send-message="emit('sendTwilioMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="shouldShowEmojiButton"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
v-if="isRegularMessageMode"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+28
-1
@@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({
|
||||
isTwilioSMS:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'sms',
|
||||
isTwilioWhatsapp:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'whatsapp',
|
||||
}));
|
||||
|
||||
const whatsappMessageTemplates = computed(() =>
|
||||
@@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
const twilioMessagePayload = prepareWhatsAppMessagePayload({
|
||||
targetInbox: props.targetInbox,
|
||||
selectedContact: props.selectedContact,
|
||||
message,
|
||||
templateParams,
|
||||
currentUser: props.currentUser,
|
||||
});
|
||||
await emit('createConversation', {
|
||||
payload: twilioMessagePayload,
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:is-loading="isCreating"
|
||||
@@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
@send-whatsapp-message="handleSendWhatsappMessage"
|
||||
@send-twilio-message="handleSendTwilioMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #actions="{ sendMessage, goBack, disabled }">
|
||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContentTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
return inboxData?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return contentTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase()) &&
|
||||
template.status === 'approved'
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+2
-1
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -53,6 +53,7 @@ const transformInbox = ({
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
validator: value => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (!value.friendly_name) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
|
||||
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
|
||||
});
|
||||
|
||||
const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
|
||||
return matches.map(match => match.replace(/[{}]/g, ''));
|
||||
});
|
||||
|
||||
const renderedTemplate = computed(() => {
|
||||
let rendered = templateBody.value;
|
||||
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
|
||||
// Replace variables in the format {{1}}, {{2}}, etc.
|
||||
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
|
||||
const cleanVariable = variable.trim();
|
||||
return processedParams.value[cleanVariable] || match;
|
||||
});
|
||||
}
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(
|
||||
() => hasVariables.value || hasMediaVariable.value
|
||||
),
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const initializeTemplateParameters = () => {
|
||||
processedParams.value = {};
|
||||
|
||||
if (hasVariables.value) {
|
||||
variablePattern.value.forEach(variable => {
|
||||
processedParams.value[variable] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaVariable.value && mediaVariableKey.value) {
|
||||
processedParams.value[mediaVariableKey.value] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid || isFormInvalid.value) return;
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
templateParams: {
|
||||
name: friendly_name,
|
||||
language,
|
||||
processed_params: processedParameters,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
|
||||
onMounted(initializeTemplateParameters);
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
initializeTemplateParameters();
|
||||
v$.value.$reset();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
processedParams,
|
||||
hasVariables,
|
||||
hasMediaTemplate,
|
||||
renderedTemplate,
|
||||
v$,
|
||||
sendMessage,
|
||||
resetTemplate,
|
||||
goBack,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ template.friendly_name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ renderedTemplate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ categoryLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVariables || hasMediaVariable">
|
||||
<!-- Media URL for media templates -->
|
||||
<div v-if="hasMediaVariable" class="mb-4">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
|
||||
</p>
|
||||
<div class="flex items-center mb-2.5">
|
||||
<Input
|
||||
v-model="processedParams[mediaVariableKey]"
|
||||
type="url"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
templateMediaUrl ||
|
||||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Variables Section -->
|
||||
<div v-if="hasVariables">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="variable in variablePattern"
|
||||
:key="`variable-${variable}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[variable]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||
variable: variable,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
|
||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||
>
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="actions"
|
||||
:send-message="sendMessage"
|
||||
:reset-template="resetTemplate"
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid && !isFormInvalid"
|
||||
:disabled="isFormInvalid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,6 +95,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -121,6 +125,7 @@ export default {
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -347,6 +352,15 @@ export default {
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
@@ -361,7 +375,7 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TemplatesPicker from './ContentTemplatesPicker.vue';
|
||||
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContentTemplate = ref(null);
|
||||
|
||||
const localShow = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
},
|
||||
});
|
||||
|
||||
const modalHeaderContent = computed(() => {
|
||||
return selectedContentTemplate.value
|
||||
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: selectedContentTemplate.value.friendly_name,
|
||||
})
|
||||
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
|
||||
});
|
||||
|
||||
const pickTemplate = template => {
|
||||
selectedContentTemplate.value = template;
|
||||
};
|
||||
|
||||
const onResetTemplate = () => {
|
||||
selectedContentTemplate.value = null;
|
||||
};
|
||||
|
||||
const onSendMessage = message => {
|
||||
emit('onSend', message);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="px-8 py-6 row">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedContentTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedContentTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="flex-1"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
class="flex-1"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TemplateParser>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const twilioTemplates = computed(() => {
|
||||
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
|
||||
return inbox?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
twilioTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(query.value.toLowerCase()) && template.status === 'approved'
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(template, i) in filteredTemplateMessages"
|
||||
:key="template.content_sid"
|
||||
>
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.friendly_name }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ getTemplateType(template) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category || 'utility' }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ new Date(template.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr
|
||||
v-if="i != filteredTemplateMessages.length - 1"
|
||||
:key="`hr-${i}`"
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && twilioTemplates.length">
|
||||
<p>
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!twilioTemplates.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@@ -52,8 +53,8 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
Banner,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
},
|
||||
@@ -109,6 +111,7 @@ export default {
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
showContentTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
@@ -155,38 +158,12 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
});
|
||||
},
|
||||
},
|
||||
showSelfAssignBanner() {
|
||||
if (this.message !== '' && !this.isOnPrivateNote) {
|
||||
if (!this.assignedAgent) {
|
||||
return true;
|
||||
}
|
||||
if (this.assignedAgent.id !== this.currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
@@ -659,28 +636,11 @@ export default {
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
} = this.currentUser;
|
||||
const selfAssign = {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
thumbnail: avatar_url,
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
openContentTemplateModal() {
|
||||
this.showContentTemplatesModal = true;
|
||||
},
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
@@ -774,6 +734,13 @@ export default {
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
@@ -1099,16 +1066,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
@@ -1217,6 +1175,7 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1239,6 +1198,7 @@ export default {
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@@ -1251,6 +1211,14 @@ export default {
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<ContentTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showContentTemplatesModal"
|
||||
@close="hideContentTemplatesModal"
|
||||
@on-send="onSendContentTemplateReply"
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
@@ -1264,10 +1232,6 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.banner--self-assign {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const assignedAgent = computed({
|
||||
get() {
|
||||
return currentChat.value?.meta?.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
);
|
||||
|
||||
const showSelfAssignBanner = computed(() => {
|
||||
return (
|
||||
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
const { avatar_url, ...rest } = currentUser.value || {};
|
||||
assignedAgent.value = { ...rest, thumbnail: avatar_url };
|
||||
};
|
||||
|
||||
const needsAssignmentToCurrentUser = computed(() => {
|
||||
return isUnassigned.value || isAssignedToOtherAgent.value;
|
||||
});
|
||||
|
||||
const onClickSelfAssign = async () => {
|
||||
try {
|
||||
await selfAssignConversation();
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner && !showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasValidAvatarUrl,
|
||||
timeStampAppendedURL,
|
||||
getHostNameFromURL,
|
||||
extractFilenameFromUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
|
||||
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFilenameFromUrl', () => {
|
||||
it('should extract filename from a valid URL', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
|
||||
).toBe('file.jpg');
|
||||
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
|
||||
'image.png'
|
||||
);
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/folder/document.pdf?query=1'
|
||||
)
|
||||
).toBe('document.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.txt#section')
|
||||
).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should handle URLs without filename', () => {
|
||||
expect(extractFilenameFromUrl('https://example.com/')).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
expect(extractFilenameFromUrl('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
|
||||
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(extractFilenameFromUrl('')).toBe('');
|
||||
expect(extractFilenameFromUrl(null)).toBe(null);
|
||||
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
|
||||
expect(extractFilenameFromUrl(123)).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters and fragments', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/file.jpg?size=large&format=png'
|
||||
)
|
||||
).toBe('file.jpg');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
|
||||
).toBe('file.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
|
||||
).toBe('file.doc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -612,6 +612,15 @@
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"TWILIO_OPTIONS": {
|
||||
"LABEL": "Select template",
|
||||
"SEARCH_PLACEHOLDER": "Search templates",
|
||||
"EMPTY_STATE": "No templates found",
|
||||
"TEMPLATE_PARSER": {
|
||||
"BACK": "Go back",
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"CONTENT_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Twilio Templates",
|
||||
"SUBTITLE": "Select the Twilio template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_CONTENT": "No content",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"CATEGORY": "Category",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
},
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"MEDIA_URL_LABEL": "Enter full media URL",
|
||||
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
|
||||
},
|
||||
"FORM": {
|
||||
"BACK_BUTTON": "Back",
|
||||
"SEND_MESSAGE_BUTTON": "Send Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
|
||||
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
|
||||
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
|
||||
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
|
||||
@@ -35,6 +35,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -74,4 +75,5 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
};
|
||||
|
||||
+13
@@ -125,6 +125,19 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
v-if="isATwilioWhatsAppChannel"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
|
||||
@@ -33,13 +33,15 @@ export default {
|
||||
brandRedirectURL() {
|
||||
try {
|
||||
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
|
||||
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
|
||||
referrerHost ? 'widget_branding' : 'survey_branding'
|
||||
}`;
|
||||
const url = new URL(this.globalConfig.widgetBrandURL);
|
||||
if (referrerHost) {
|
||||
return `${baseURL}&utm_referrer=${referrerHost}`;
|
||||
url.searchParams.set('utm_source', referrerHost);
|
||||
url.searchParams.set('utm_medium', 'widget');
|
||||
} else {
|
||||
url.searchParams.set('utm_medium', 'survey');
|
||||
}
|
||||
return baseURL;
|
||||
url.searchParams.set('utm_campaign', 'branding');
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
// Suppressing the error as getter is not defined in some cases
|
||||
}
|
||||
|
||||
@@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = {
|
||||
location: 'location',
|
||||
fallback: 'link',
|
||||
};
|
||||
|
||||
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
|
||||
TEXT: 'text',
|
||||
MEDIA: 'media',
|
||||
QUICK_REPLY: 'quick_reply',
|
||||
};
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
* Writes a text string to the system clipboard.
|
||||
*
|
||||
* @async
|
||||
* @param {string} text text to be written to the clipboard
|
||||
* @param {string} data text to be written to the clipboard
|
||||
* @throws {Error} unable to copy text to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async text => {
|
||||
export const copyTextToClipboard = async data => {
|
||||
try {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { copyTextToClipboard } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('copies plain text string to clipboard', async () => {
|
||||
const text = 'Hello World';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('copies empty string to clipboard', async () => {
|
||||
const text = '';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with number input', () => {
|
||||
it('converts number to string', async () => {
|
||||
await copyTextToClipboard(42);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('42');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts zero to string', async () => {
|
||||
await copyTextToClipboard(0);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('0');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with boolean input', () => {
|
||||
it('converts true to string', async () => {
|
||||
await copyTextToClipboard(true);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('true');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts false to string', async () => {
|
||||
await copyTextToClipboard(false);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('false');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with null/undefined input', () => {
|
||||
it('converts null to empty string', async () => {
|
||||
await copyTextToClipboard(null);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts undefined to empty string', async () => {
|
||||
await copyTextToClipboard(undefined);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('stringifies simple object with proper formatting', async () => {
|
||||
const obj = { name: 'John', age: 30 };
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
const expectedJson = JSON.stringify(obj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies nested object with proper formatting', async () => {
|
||||
const nestedObj = {
|
||||
severity: {
|
||||
user_id: 1181505,
|
||||
user_name: 'test',
|
||||
server_name: '[1253]test1253',
|
||||
},
|
||||
};
|
||||
await copyTextToClipboard(nestedObj);
|
||||
|
||||
const expectedJson = JSON.stringify(nestedObj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies array with proper formatting', async () => {
|
||||
const arr = [1, 2, { name: 'test' }];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
const expectedJson = JSON.stringify(arr, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty object', async () => {
|
||||
const obj = {};
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('{}');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty array', async () => {
|
||||
const arr = [];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('[]');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws error when clipboard API fails', async () => {
|
||||
const error = new Error('Clipboard access denied');
|
||||
mockWriteText.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard: Clipboard access denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles clipboard API not available', async () => {
|
||||
// Temporarily remove clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
delete navigator.clipboard;
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard:'
|
||||
);
|
||||
|
||||
// Restore clipboard API
|
||||
navigator.clipboard = originalClipboard;
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Date objects', async () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
await copyTextToClipboard(date);
|
||||
|
||||
const expectedJson = JSON.stringify(date, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles functions by converting to string', async () => {
|
||||
const func = () => 'test';
|
||||
await copyTextToClipboard(func);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -26,7 +26,7 @@ const locale = computed(() => {
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
if (portal.value && !popularArticles.value.length) {
|
||||
if (portal.value && locale.value) {
|
||||
store.dispatch('article/fetch', {
|
||||
slug: portal.value.slug,
|
||||
locale: locale.value,
|
||||
@@ -60,6 +60,14 @@ const hasArticles = computed(
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
|
||||
// Watch for locale changes and refetch articles
|
||||
watch(locale, (newLocale, oldLocale) => {
|
||||
if (newLocale && newLocale !== oldLocale) {
|
||||
fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user