Merge branch 'develop' into fix/CW-6859

This commit is contained in:
Sivin Varghese
2026-04-27 15:42:41 +05:30
committed by GitHub
107 changed files with 3688 additions and 586 deletions
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
@@ -112,11 +112,14 @@ const selectedModel = computed({
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div v-if="!isInboxView" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div
v-tooltip.top="{
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
@@ -155,7 +155,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -233,6 +233,7 @@ onMounted(() => resetContacts());
<Popover
ref="popoverRef"
:align="align"
:show-content-border="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -20,6 +20,7 @@ const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
// eslint-disable-next-line vue/no-unused-properties
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -198,7 +199,6 @@ useEventListener(document, 'paste', onPaste);
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
@@ -24,9 +24,7 @@ const handleBack = () => {
</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="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<ContentTemplateParser
:template="template"
@@ -6,6 +6,7 @@ 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 Popover from 'dashboard/components-next/popover/Popover.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
@@ -22,7 +23,6 @@ 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);
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
);
});
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
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>
<template #content="{ hide }">
<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)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
<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>
<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
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>
<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>
<ContentTemplateForm
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -5,6 +5,7 @@ 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 Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
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="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
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>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.id"
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)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
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 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>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</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.WHATSAPP_OPTIONS.EMPTY_STATE')
}}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<WhatsappTemplate
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -24,9 +24,7 @@ const handleBack = () => {
</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="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@@ -12,6 +12,14 @@ const props = defineProps({
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
disableMobileView: {
type: Boolean,
default: false,
},
showContentBorder: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['show', 'hide']);
@@ -22,7 +30,8 @@ const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const belowMd = breakpoints.smaller('md');
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
@@ -99,9 +108,13 @@ defineExpose({ show, hide, toggle });
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
</div>
@@ -113,9 +126,14 @@ defineExpose({ show, hide, toggle });
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
:class="{ 'border border-n-strong': showContentBorder }"
>
<slot name="content" :hide="hide" />
</div>
</div>
</TeleportWithDirection>
</template>
@@ -65,12 +65,11 @@ defineExpose({ conversationListRef });
>
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
v-slot="{ item }"
:data="conversationList"
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
>
<ConversationItem
:key="index"
:source="item"
:label="label"
:team-id="teamId"
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -745,6 +745,7 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
@@ -60,9 +60,7 @@ const onDelete = async hide => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-80 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
@@ -72,9 +72,7 @@ const onMergeContacts = async (parentContactId, hide) => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-96 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('MERGE_CONTACTS.TITLE') }}
@@ -125,7 +125,7 @@ export default {
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
const priority = priorityItem.id;
this.$store.dispatch('setCurrentChatPriority', {
priority,
@@ -203,7 +203,9 @@ export default {
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
this.assignedPriority = isSamePriority
? this.priorityOptions[0]
: selectedPriorityItem;
},
},
};
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
</div>
</template>
@@ -112,9 +112,33 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
}),
isInboundEmailEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.INBOUND_EMAILS
);
},
showContinuityToggle() {
if (this.isInboundEmailEnabled) return true;
return this.isOnChatwootCloud;
},
isContinuityDisabled() {
return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
},
continuityDescription() {
if (this.isContinuityDisabled) {
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
);
}
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
);
},
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@@ -542,7 +566,8 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
continuity_via_email: this.continuityViaEmail,
continuity_via_email:
this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.avatarFile) {
@@ -1148,15 +1173,15 @@ export default {
/>
<SettingsToggleSection
v-if="isAWebWidgetInbox"
v-if="isAWebWidgetInbox && showContinuityToggle"
v-model="continuityViaEmail"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
:description="continuityDescription"
:hide-toggle="isContinuityDisabled"
:class="
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
"
/>
</SettingsAccordion>
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
bulkTranslate: async (
_,
{ portalSlug, articleIds, locale, categoryId, force = false }
) => {
const { data } = await articlesAPI.bulkTranslate({
portalSlug,
articleIds,
locale,
categoryId,
force,
});
return data;
},
};
@@ -1,3 +1,5 @@
import { isApple } from './platform';
export const isEnter = e => {
return e.key === 'Enter';
};
@@ -14,13 +16,20 @@ export const hasPressedCommand = e => {
return e.metaKey;
};
// True when the platform's "command" modifier is held: Cmd (metaKey) on
// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey)
// elsewhere. Mirrors the `$mod` convention used by tinykeys and
// prosemirror-keymap so the editor and the app agree on what counts as the
// send modifier.
export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey);
export const hasPressedEnterAndNotCmdOrShift = e => {
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e);
};
export const hasPressedCommandAndEnter = e => {
return hasPressedCommand(e) && isEnter(e);
};
// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms,
// Ctrl+Enter on Windows/Linux.
export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e);
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
+50
View File
@@ -0,0 +1,50 @@
// Detects the current OS using the modern User-Agent Client Hints API,
// falling back to userAgent parsing on Safari/Firefox where it is unavailable.
// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints.
export const OS = Object.freeze({
MAC: 'macos',
WINDOWS: 'windows',
LINUX: 'linux',
ANDROID: 'android',
IOS: 'ios',
UNKNOWN: 'unknown',
});
// navigator.userAgentData.platform → OS constant (lowercased keys)
const UAD_MAP = {
macos: OS.MAC,
windows: OS.WINDOWS,
linux: OS.LINUX,
android: OS.ANDROID,
ios: OS.IOS,
};
export function detectOS() {
if (typeof navigator === 'undefined') return OS.UNKNOWN;
// Trust userAgentData only when it maps to a known OS; otherwise fall
// through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak.
const uad = navigator.userAgentData?.platform?.toLowerCase();
if (uad && UAD_MAP[uad]) return UAD_MAP[uad];
const ua = navigator.userAgent || '';
if (/android/i.test(ua)) return OS.ANDROID;
if (/iPhone|iPod/.test(ua)) return OS.IOS;
if (
/iPad/.test(ua) ||
(/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1)
) {
return OS.IOS;
}
if (/Win/i.test(ua)) return OS.WINDOWS;
if (/Mac/i.test(ua)) return OS.MAC;
if (/Linux/i.test(ua)) return OS.LINUX;
return OS.UNKNOWN;
}
export const isApple = () => {
const os = detectOS();
return os === OS.MAC || os === OS.IOS;
};
@@ -3,9 +3,29 @@ import {
isEscape,
hasPressedShift,
hasPressedCommand,
hasPressedMod,
hasPressedCommandAndEnter,
hasPressedEnterAndNotCmdOrShift,
isActiveElementTypeable,
} from '../KeyboardHelpers';
const setNavigator = navigatorValue => {
Object.defineProperty(global, 'navigator', {
value: navigatorValue,
configurable: true,
writable: true,
});
};
const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
const onWindows = () =>
setNavigator({ userAgentData: { platform: 'Windows' } });
const onIOS = () =>
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
describe('#KeyboardHelpers', () => {
describe('#isEnter', () => {
it('return correct values', () => {
@@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
});
});
describe('#hasPressedMod', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('uses metaKey on macOS', () => {
onMac();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('uses ctrlKey on Windows', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
});
it('uses metaKey on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
});
it('returns false when no modifier is held', () => {
onWindows();
expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
});
});
describe('#hasPressedCommandAndEnter', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for Cmd+Enter on macOS', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
true
);
});
it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
onMac();
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
false
);
});
it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
onIOS();
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
true
);
});
it('returns false for plain Enter', () => {
onWindows();
expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
});
});
describe('#hasPressedEnterAndNotCmdOrShift', () => {
const originalNavigator = global.navigator;
afterEach(() => {
setNavigator(originalNavigator);
});
it('returns true for plain Enter on Windows', () => {
onWindows();
expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
});
it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
).toBe(false);
});
it('returns false for Cmd+Enter on macOS (mod is held)', () => {
onMac();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
).toBe(false);
});
it('returns false for Shift+Enter', () => {
onWindows();
expect(
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
).toBe(false);
});
});
});
describe('isActiveElementTypeable', () => {
@@ -0,0 +1,186 @@
import { detectOS, isApple, OS } from '../platform';
const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => {
Object.defineProperty(global, 'navigator', {
value: { userAgentData, userAgent, maxTouchPoints },
configurable: true,
writable: true,
});
};
describe('detectOS', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
describe('with userAgentData available', () => {
it('returns OS.MAC for macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(detectOS()).toBe(OS.MAC);
});
it('returns OS.WINDOWS for Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(detectOS()).toBe(OS.WINDOWS);
});
it('returns OS.LINUX for Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(detectOS()).toBe(OS.LINUX);
});
it('returns OS.ANDROID for Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(detectOS()).toBe(OS.ANDROID);
});
it('falls through to userAgent for unmapped values like "Chrome OS"', () => {
setNavigator({
userAgentData: { platform: 'Chrome OS' },
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36',
});
// Not a mapped UAD value AND not a recognized UA pattern → unknown
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('prefers userAgentData over userAgent when value is mapped', () => {
setNavigator({
userAgentData: { platform: 'Windows' },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
});
describe('with userAgent fallback', () => {
it('detects macOS from Safari userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.MAC);
});
it('detects Windows from userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.WINDOWS);
});
it('detects Linux from userAgent', () => {
setNavigator({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.LINUX);
});
it('detects Android from userAgent (before Linux match)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
});
expect(detectOS()).toBe(OS.ANDROID);
});
it('detects iOS from iPhone userAgent', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(detectOS()).toBe(OS.IOS);
});
it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(detectOS()).toBe(OS.IOS);
});
it('returns OS.UNKNOWN when no match', () => {
setNavigator({ userAgent: 'SomeRandomBot/1.0' });
expect(detectOS()).toBe(OS.UNKNOWN);
});
it('returns OS.UNKNOWN when userAgent is missing', () => {
setNavigator({});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
describe('without navigator', () => {
it('returns OS.UNKNOWN when navigator is undefined', () => {
Object.defineProperty(global, 'navigator', {
value: undefined,
configurable: true,
writable: true,
});
expect(detectOS()).toBe(OS.UNKNOWN);
});
});
});
describe('isApple', () => {
const originalNavigator = global.navigator;
afterEach(() => {
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
configurable: true,
writable: true,
});
});
it('returns true on macOS', () => {
setNavigator({ userAgentData: { platform: 'macOS' } });
expect(isApple()).toBe(true);
});
it('returns true on iOS (iPhone)', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
});
expect(isApple()).toBe(true);
});
it('returns true on iPadOS spoofing Macintosh', () => {
setNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
maxTouchPoints: 5,
});
expect(isApple()).toBe(true);
});
it('returns false on Windows', () => {
setNavigator({ userAgentData: { platform: 'Windows' } });
expect(isApple()).toBe(false);
});
it('returns false on Linux', () => {
setNavigator({ userAgentData: { platform: 'Linux' } });
expect(isApple()).toBe(false);
});
it('returns false on Android', () => {
setNavigator({ userAgentData: { platform: 'Android' } });
expect(isApple()).toBe(false);
});
});
describe('OS constants', () => {
it('is frozen so callers cannot mutate it', () => {
expect(Object.isFrozen(OS)).toBe(true);
});
});
+84 -84
View File
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
"DOWNLOAD": "Laadi alla",
"UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
"SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
"RETRY": "Send message again",
"ERROR_MESSAGE": "Couldn't send, try again"
"RETRY": "Saada sõnum uuesti",
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"ONLINE": "Oleme võrgus",
"OFFLINE": "Oleme hetkel eemal",
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
"BACK_AT_TIME": "Oleme tagasi kell {time}",
"BACK_ON_DAY": "Oleme tagasi {day}",
"BACK_TOMORROW": "Oleme tagasi homme",
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
"MONDAY": "Monday",
"TUESDAY": "Tuesday",
"WEDNESDAY": "Wednesday",
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
"SUNDAY": "Pühapäev",
"MONDAY": "Esmaspäev",
"TUESDAY": "Teisipäev",
"WEDNESDAY": "Kolmapäev",
"THURSDAY": "Neljapäev",
"FRIDAY": "Reede",
"SATURDAY": "Laupäev"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"START_NEW_CONVERSATION": "Start a new conversation",
"VIEW_UNREAD_MESSAGES": "You have unread messages",
"START_CONVERSATION": "Alusta vestlust",
"END_CONVERSATION": "Lõpeta vestlus",
"CONTINUE_CONVERSATION": "Jätka vestlust",
"YOU": "Sina",
"START_NEW_CONVERSATION": "Alusta uut vestlust",
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close",
"COMPANY_FROM": "from",
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
"CLOSE_MESSAGES_BUTTON": "Sulge",
"COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
"LABEL": "Chat with us"
"LABEL": "Vestle meiega"
},
"POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"POWERED_BY": "Toetab Chatwoot",
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
"TODAY": "Täna",
"YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"REQUIRED_ERROR": "Full Name is required"
"LABEL": "Täisnimi",
"PLACEHOLDER": "Palun sisesta oma täisnimi",
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"REQUIRED_ERROR": "Email Address is required",
"VALID_ERROR": "Please enter a valid email address"
"LABEL": "E-posti aadress",
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter your phone number",
"REQUIRED_ERROR": "Phone Number is required",
"DIAL_CODE_VALID_ERROR": "Please select a country code",
"VALID_ERROR": "Please enter a valid phone number",
"DROPDOWN_EMPTY": "No results found",
"DROPDOWN_SEARCH": "Search country"
"LABEL": "Telefoninumber",
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
"DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
"LABEL": "Sõnum",
"PLACEHOLDER": "Palun sisesta oma sõnum",
"ERROR": "Sõnum on liiga lühike"
}
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
"IS_REQUIRED": "on kohustuslik",
"REQUIRED": "Kohustuslik",
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
"FIELD": "Invalid field"
"FIELD": "Vigane väli"
}
},
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
"PLACEHOLDER": "Otsi emotikone",
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
"PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
"BUTTON_TEXT": "Request a conversation transcript",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again"
"BUTTON_TEXT": "Taotle vestluse koopiat",
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
"CLICK_HERE_TO_JOIN": "Click here to join",
"LEAVE_THE_ROOM": "Leave the call"
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
"LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"POPULAR_ARTICLES": "Populaarsed artiklid",
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
"CONTENT": "Picture message"
"CONTENT": "Pildisõnum"
},
"audio": {
"CONTENT": "Audio message"
"CONTENT": "Helisõnum"
},
"video": {
"CONTENT": "Video message"
"CONTENT": "Videosõnum"
},
"file": {
"CONTENT": "File Attachment"
"CONTENT": "Faili manus"
},
"location": {
"CONTENT": "Location"
"CONTENT": "Asukoht"
},
"fallback": {
"CONTENT": "has shared a url"
"CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
"REPLY_TO": "Replying to:"
"REPLY_TO": "Vastus sõnumile:"
}
}