Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
<script setup>
import { computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
]);
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({
get: () => props.article.title,
set: value => {
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
handleSave({ content });
},
});
const onClickGoBack = () => {
emit('goBack');
};
const setAuthorId = authorId => {
emit('setAuthor', authorId);
};
const setCategoryId = categoryId => {
emit('setCategory', categoryId);
};
const previewArticle = () => {
emit('previewArticle');
};
</script>
<template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions>
<ArticleEditorHeader
:is-updating="isUpdating"
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
@go-back="onClickGoBack"
@preview-article="previewArticle"
/>
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea
v-model="articleTitle"
auto-height
min-height="4rem"
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
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</div>
<FullEditor
v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
:placeholder="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
/>
</template>
</HelpCenterLayout>
</template>
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-n-slate-10 text-base;
}
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
display: none; // Hide by default
}
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>

View File

@@ -0,0 +1,266 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
const { t } = useI18n();
const route = useRoute();
const openAgentsList = ref(false);
const openCategoryList = ref(false);
const openProperties = ref(false);
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const agents = useMapGetter('agents/getAgents');
const categories = useMapGetter('categories/allCategories');
const currentUserId = useMapGetter('getCurrentUserID');
const isNewArticle = computed(() => !props.article?.id);
const currentUser = computed(() =>
agents.value.find(agent => agent.id === currentUserId.value)
);
const categorySlugFromRoute = computed(() => route.params.categorySlug);
const author = computed(() => {
if (isNewArticle.value) {
return selectedAuthorId.value
? agents.value.find(agent => agent.id === selectedAuthorId.value)
: currentUser.value;
}
return props.article?.author || null;
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || ''
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
const agentList = computed(() => {
return (
agents.value
?.map(({ name, id, thumbnail }) => ({
label: name,
value: id,
thumbnail: { name, src: thumbnail },
isSelected: props.article?.author?.id
? id === props.article.author.id
: id === (selectedAuthorId.value || currentUserId.value),
action: 'assignAuthor',
}))
// Sort the list by isSelected first, then by name(label)
.toSorted((a, b) => {
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
return a.label.localeCompare(b.label);
}) ?? []
);
});
const hasAgentList = computed(() => {
return agents.value?.length > 1;
});
const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (categorySlugFromRoute.value) {
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
);
});
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon }) => ({
label: name,
value: id,
emoji: icon,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
action: 'assignCategory',
}))
// Sort categories by isSelected
.toSorted((a, b) => Number(b.isSelected) - Number(a.isSelected))
);
});
const hasCategoryMenuItems = computed(() => {
return categoryList.value?.length > 0;
});
const handleArticleAction = ({ action, value }) => {
const actions = {
assignAuthor: () => {
if (isNewArticle.value) {
selectedAuthorId.value = value;
emit('setAuthor', value);
} else {
emit('saveArticle', { author_id: value });
}
openAgentsList.value = false;
},
assignCategory: () => {
if (isNewArticle.value) {
selectedCategoryId.value = value;
emit('setCategory', value);
} else {
emit('saveArticle', { category_id: value });
}
openCategoryList.value = false;
},
};
actions[action]?.();
};
const updateMeta = meta => {
emit('saveArticle', { meta });
};
onMounted(() => {
if (categorySlugFromRoute.value && isNewArticle.value) {
// Assign category from slug if there is one
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
if (categoryFromSlug) {
handleArticleAction({
action: 'assignCategory',
value: categoryFromSlug?.id,
});
}
}
});
</script>
<template>
<div class="flex items-center gap-4">
<div class="relative flex items-center gap-2">
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
color="slate"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
{{ authorName || '-' }}
</span>
</Button>
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
<span
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
</span>
</Button>
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
"
icon="i-lucide-plus"
variant="ghost"
color="slate"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
/>
<ArticleEditorProperties
v-if="openProperties"
:article="article"
class="right-0 z-[100] mt-2 xl:left-0 top-full"
@save-article="updateMeta"
@close="openProperties = false"
/>
</OnClickOutside>
</div>
</div>
</template>

View File

@@ -0,0 +1,180 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
ARTICLE_EDITOR_STATUS_OPTIONS,
ARTICLE_STATUSES,
ARTICLE_MENU_ITEMS,
} from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
articleId: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isArticlePublishing = ref(false);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
value,
action: 'update-status',
icon,
};
});
});
const statusText = computed(() =>
t(
`HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.STATUS.${props.isUpdating ? 'SAVING' : 'SAVED'}`
)
);
const onClickGoBack = () => emit('goBack');
const previewArticle = () => emit('previewArticle');
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
};
return statusMap[status]
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
: '';
};
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
}
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: props.articleId,
status,
});
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
isArticlePublishing.value = false;
}
};
</script>
<template>
<div class="flex items-center justify-between h-20">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')"
icon="i-lucide-chevron-left"
variant="link"
color="slate"
size="sm"
class="ltr:pl-3 rtl:pr-3"
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
size="sm"
:disabled="!articleId"
@click="previewArticle"
/>
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
<Button
icon="i-lucide-chevron-down"
size="sm"
:disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu"
/>
<DropdownMenu
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,135 @@
<script setup>
import { reactive, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
article: {
type: Object,
required: true,
},
});
const emit = defineEmits(['saveArticle', 'close']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const { t } = useI18n();
const state = reactive({
title: '',
description: '',
tags: [],
});
const updateState = () => {
state.title = props.article.meta?.title || '';
state.description = props.article.meta?.description || '';
state.tags = props.article.meta?.tags || [];
};
watch(
state,
newState => {
saveArticle({
title: newState.title,
description: newState.description,
tags: newState.tags,
});
},
{ deep: true }
);
onMounted(() => {
updateState();
});
</script>
<template>
<div
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES'
)
}}
</h3>
<Button
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
</div>
<div class="flex flex-col gap-2">
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-n-slate-12"
>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION'
)
}}
</label>
<TextArea
v-model="state.description"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[9.375rem]"
auto-height
min-height="3rem"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<InlineInput
v-model="state.title"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE_PLACEHOLDER'
)
"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[7.5rem]"
/>
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-n-slate-12"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
}}
</label>
<TagInput
v-model="state.tags"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[14rem]"
/>
</div>
</div>
</div>
</div>
</template>