feat: Add popular content per locale (#14939)
This commit is contained in:
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
'select-popular-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -26,6 +27,7 @@ const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
const popularContentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'select-popular-content') {
|
||||
popularContentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_CATEGORIES = 3;
|
||||
const MAX_ARTICLES = 6;
|
||||
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
// One flag per picker so each shows its skeleton until its own data loads.
|
||||
const categoriesLoading = ref(false);
|
||||
const { run: runArticleRequest, isPending: articlesLoading } =
|
||||
useAbortableRequest();
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
const selectedCategoryIds = ref([]);
|
||||
|
||||
// Articles are searched server-side, so cache options for selected items that
|
||||
// fall outside the current results.
|
||||
const articleResults = ref([]);
|
||||
const articleOptionById = ref({});
|
||||
const selectedArticleIds = ref([]);
|
||||
|
||||
const popularContent = computed(
|
||||
() => props.portal?.config?.popular_content || {}
|
||||
);
|
||||
|
||||
const articleOptions = computed(() => {
|
||||
const options = new Map();
|
||||
selectedArticleIds.value.forEach(id => {
|
||||
if (articleOptionById.value[id])
|
||||
options.set(id, articleOptionById.value[id]);
|
||||
});
|
||||
articleResults.value.forEach(option => options.set(option.value, option));
|
||||
return [...options.values()];
|
||||
});
|
||||
|
||||
const toArticleOption = article => ({
|
||||
value: article.id,
|
||||
label: article.title,
|
||||
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
|
||||
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchCategories = async localeCode => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await categoriesAPI.get({
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: localeCode,
|
||||
});
|
||||
return payload.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
|
||||
count: category.meta?.articles_count || 0,
|
||||
}),
|
||||
icon: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
};
|
||||
|
||||
const requestArticles = async (query, signal) => {
|
||||
const { data } = await articlesAPI.getArticles({
|
||||
pageNumber: 1,
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: activeLocale.value,
|
||||
status: 'published',
|
||||
query,
|
||||
signal,
|
||||
});
|
||||
articleResults.value = data.payload.map(article => {
|
||||
const option = toArticleOption(article);
|
||||
articleOptionById.value[article.id] = option;
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const searchArticles = (query = '') =>
|
||||
runArticleRequest(signal => requestArticles(query, signal));
|
||||
|
||||
const onArticleSearch = useDebounceFn(searchArticles, 300);
|
||||
|
||||
// Resolve options for pre-selected articles that aren't in the current results.
|
||||
const cacheSelectedArticleOptions = async () => {
|
||||
const unknownIds = selectedArticleIds.value.filter(
|
||||
id => !articleOptionById.value[id]
|
||||
);
|
||||
await Promise.all(
|
||||
unknownIds.map(async id => {
|
||||
try {
|
||||
const { data } = await articlesAPI.getArticle({
|
||||
id,
|
||||
portalSlug: props.portal?.slug,
|
||||
});
|
||||
articleOptionById.value[id] = toArticleOption(data.payload);
|
||||
} catch {
|
||||
// Deleted since it was picked; leave it for the id fallback.
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const loadCategories = async localeCode => {
|
||||
categoriesLoading.value = true;
|
||||
try {
|
||||
const options = await fetchCategories(localeCode);
|
||||
// Reopened for another locale mid-flight; drop the stale response.
|
||||
if (localeCode !== activeLocale.value) return;
|
||||
categoryOptions.value = options;
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
} finally {
|
||||
if (localeCode === activeLocale.value) categoriesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
try {
|
||||
await runArticleRequest(signal =>
|
||||
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = popularContent.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
selectedCategoryIds.value = [...(existing.category_ids || [])];
|
||||
selectedArticleIds.value = [...(existing.article_ids || [])];
|
||||
categoryOptions.value = [];
|
||||
articleResults.value = [];
|
||||
articleOptionById.value = {};
|
||||
dialogRef.value?.open();
|
||||
|
||||
loadCategories(localeCode);
|
||||
loadArticles();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const updated = { ...popularContent.value };
|
||||
const entry = {
|
||||
category_ids: selectedCategoryIds.value,
|
||||
article_ids: selectedArticleIds.value,
|
||||
};
|
||||
|
||||
if (entry.category_ids.length || entry.article_ids.length) {
|
||||
updated[activeLocale.value] = entry;
|
||||
} else {
|
||||
delete updated[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { popular_content: updated },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t(`${KEY}.TITLE`)"
|
||||
:description="t(`${KEY}.DESCRIPTION`)"
|
||||
:confirm-button-label="t(`${KEY}.CONFIRM`)"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedCategoryIds"
|
||||
:options="categoryOptions"
|
||||
:max="MAX_CATEGORIES"
|
||||
:label="t(`${KEY}.CATEGORIES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
fallback-icon="i-lucide-folder"
|
||||
:loading="categoriesLoading"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedArticleIds"
|
||||
server-search
|
||||
:options="articleOptions"
|
||||
:max="MAX_ARTICLES"
|
||||
:label="t(`${KEY}.ARTICLES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
:loading="articlesLoading"
|
||||
@search="onArticleSearch"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user