feat: Show popular articles on widget home (#7604)

This commit is contained in:
Nithin David Thomas
2023-08-01 21:32:44 +05:30
committed by GitHub
parent 9efadf8804
commit e052a061f4
22 changed files with 299 additions and 64 deletions

View File

@@ -280,7 +280,7 @@ export const SDK_CSS = `
.woot-widget-holder { .woot-widget-holder {
border-radius: 16px; border-radius: 16px;
bottom: 104px; bottom: 104px;
height: calc(85% - 64px - 20px); height: calc(90% - 64px - 20px);
max-height: 590px !important; max-height: 590px !important;
min-height: 250px !important; min-height: 250px !important;
width: 400px !important; width: 400px !important;

View File

@@ -0,0 +1,18 @@
<template>
<div class="p-6 space-y-6 bg-white">
<div class="space-y-2 animate-pulse ">
<div class="h-6 bg-slate-100 rounded w-1/5" />
<div class="h-10 bg-slate-100 rounded w-3/5" />
</div>
<div class="space-y-2 animate-pulse ">
<div class="h-5 bg-slate-100 rounded" />
<div class="h-5 bg-slate-100 rounded" />
<div class="h-5 bg-slate-100 rounded" />
</div>
<div class="space-y-2 animate-pulse ">
<div class="h-5 bg-slate-100 rounded" />
<div class="h-5 bg-slate-100 rounded" />
<div class="h-5 bg-slate-100 rounded w-7/10" />
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<template>
<div class="relative overflow-hidden pb-1/2 h-full">
<iframe
v-if="url"
:src="url"
class="absolute w-full h-full top-0 left-0"
@load="handleIframeLoad"
@error="handleIframeError"
/>
<article-skeleton-loader
v-if="isLoading"
class="absolute w-full h-full top-0 left-0"
/>
<div
v-if="showEmptyState"
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
>
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
</div>
</div>
</template>
<script>
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader';
export default {
name: 'IframeRenderer',
components: {
ArticleSkeletonLoader,
},
props: {
url: {
type: String,
default: '',
},
},
data() {
return {
isLoading: true,
showEmptyState: !this.url,
};
},
methods: {
handleIframeLoad() {
// Once loaded, the loading state is hidden
this.isLoading = false;
},
handleIframeError() {
// Hide the loading state and show the empty state when an error occurs
this.isLoading = false;
this.showEmptyState = true;
},
},
};
</script>

View File

@@ -97,6 +97,7 @@ const getMostReadArticles = (slug, locale) => ({
url: `/hc/${slug}/${locale}/articles.json`, url: `/hc/${slug}/${locale}/articles.json`,
params: { params: {
page: 1, page: 1,
sort: 'views',
}, },
}); });

View File

@@ -10,7 +10,7 @@
<div <div
v-dompurify-html="formatMessage(message, false)" v-dompurify-html="formatMessage(message, false)"
class="message-content" class="message-content"
:class="$dm('text-black-900', 'dark:text-slate-50')" :class="$dm('text-slate-900', 'dark:text-slate-50')"
/> />
<email-input <email-input
v-if="isTemplateEmail" v-if="isTemplateEmail"

View File

@@ -1,21 +1,27 @@
<template> <template>
<div class="py-2"> <div>
<h3 class="text-sm font-semibold text-slate-900 mb-0">{{ title }}</h3> <h3 class="text-base font-medium text-slate-900 dark:text-slate-50 mb-0">
<article-list :articles="articles" /> {{ title }}
</h3>
<article-list :articles="articles" @click="onArticleClick" />
<button <button
class="inline-flex text-sm font-medium rounded-md px-2 py-1 -ml-2 leading-6 text-slate-800 justify-between items-center hover:bg-slate-25 see-articles" class="inline-flex text-sm font-medium rounded-md px-2 py-1 -ml-2 leading-6 text-slate-800 dark:text-slate-50 justify-between items-center hover:bg-slate-25 see-articles"
@click="$emit('view-all-articles')" :style="{ color: widgetColor }"
@click="$emit('view-all')"
> >
<span class="pr-2">{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span> <span class="pr-2 text-sm">{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
<fluent-icon icon="arrow-right" size="14" /> <fluent-icon icon="arrow-right" size="14" />
</button> </button>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import ArticleList from './ArticleList.vue'; import ArticleList from './ArticleList.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default { export default {
components: { ArticleList }, components: { FluentIcon, ArticleList },
props: { props: {
title: { title: {
type: String, type: String,
@@ -25,9 +31,13 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryPath: { },
type: String, computed: {
default: '', ...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
},
methods: {
onArticleClick(link) {
this.$emit('view', link);
}, },
}, },
}; };

View File

@@ -1,13 +1,10 @@
<template> <template>
<div> <category-card
<h2 class="text-base font-bold leading-6 text-slate-800 mb-0"> :title="$t('PORTAL.POPULAR_ARTICLES')"
{{ $t('PORTAL.POPULAR_ARTICLES') }} :articles="articles.slice(0, 4)"
</h2> @view-all="$emit('view-all')"
<category-card @view="onArticleClick"
:articles="articles.slice(0, 4)" />
@view-all-articles="$emit('view-all-articles')"
/>
</div>
</template> </template>
<script> <script>
@@ -24,6 +21,11 @@ export default {
default: '', default: '',
}, },
}, },
methods: {
onArticleClick(link) {
this.$emit('view', link);
},
},
}; };
</script> </script>

View File

@@ -2,7 +2,7 @@
<ul role="list" class="py-2"> <ul role="list" class="py-2">
<article-list-item <article-list-item
v-for="article in articles" v-for="article in articles"
:key="article.id" :key="article.slug"
:link="article.link" :link="article.link"
:title="article.title" :title="article.title"
@click="onClick" @click="onClick"

View File

@@ -1,20 +1,24 @@
<template> <template>
<li <li
class="py-1 flex items-center justify-between -mx-1 px-1 hover:bg-slate-25" class="py-1 flex items-center justify-between -mx-1 px-1 hover:bg-slate-75 dark:hover:bg-slate-600 rounded cursor-pointer text-slate-700 dark:text-slate-50 dark:hover:text-slate-25 hover:text-slate-900 "
@click="onClick"
> >
<button <button
class="text-slate-700 hover:text-slate-900 underline-offset-2 text-sm leading-6" class="underline-offset-2 text-sm leading-6 text-left"
@click="onClick" @click="onClick"
> >
{{ title }} {{ title }}
</button> </button>
<span class="pl-1 text-slate-700 arrow"> <span class="pl-1 first-letter arrow">
<fluent-icon icon="arrow-right" size="14" /> <fluent-icon icon="arrow-right" size="14" />
</span> </span>
</li> </li>
</template> </template>
<script> <script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default { export default {
components: { FluentIcon },
props: { props: {
link: { link: {
type: String, type: String,

View File

@@ -4,7 +4,11 @@
:class="$dm('bg-white', 'dark:bg-slate-900')" :class="$dm('bg-white', 'dark:bg-slate-900')"
> >
<div class="flex items-center"> <div class="flex items-center">
<button v-if="showBackButton" @click="onBackButtonClick"> <button
v-if="showBackButton"
class="-ml-3 px-2"
@click="onBackButtonClick"
>
<fluent-icon <fluent-icon
icon="chevron-left" icon="chevron-left"
size="24" size="24"

View File

@@ -1,7 +1,7 @@
<template> <template>
<header <header
class="header-expanded py-6 px-5 relative box-border w-full" class="header-expanded py-6 px-5 relative box-border w-full"
:class="$dm('bg-white', 'dark:bg-slate-900')" :class="showBg ? 'bg-transparent' : 'bg-white dark:bg-slate-900'"
> >
<div <div
class="flex items-start" class="flex items-start"
@@ -13,23 +13,25 @@
:src="avatarUrl" :src="avatarUrl"
alt="Avatar" alt="Avatar"
/> />
<header-actions :show-popout-button="showPopoutButton" /> <header-actions
:show-popout-button="showPopoutButton"
:show-end-conversation-button="false"
/>
</div> </div>
<h2 <h2
v-dompurify-html="introHeading" v-dompurify-html="introHeading"
class="mt-5 text-3xl mb-3 leading-8 font-normal" class="mt-4 text-2xl mb-2 font-normal"
:class="$dm('text-slate-900', 'dark:text-slate-50')" :class="$dm('text-slate-900', 'dark:text-slate-50')"
/> />
<p <p
v-dompurify-html="introBody" v-dompurify-html="introBody"
class="text-lg leading-normal" class="text-base leading-normal"
:class="$dm('text-slate-700', 'dark:text-slate-200')" :class="$dm('text-slate-700', 'dark:text-slate-200')"
/> />
</header> </header>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import HeaderActions from './HeaderActions'; import HeaderActions from './HeaderActions';
import darkModeMixin from 'widget/mixins/darkModeMixin.js'; import darkModeMixin from 'widget/mixins/darkModeMixin.js';
@@ -56,11 +58,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, showBg: {
computed: { type: Boolean,
...mapGetters({ default: true,
widgetColor: 'appConfig/getWidgetColor', },
}),
}, },
}; };
</script> </script>

View File

@@ -1,7 +1,11 @@
<template> <template>
<div v-if="showHeaderActions" class="actions flex items-center"> <div v-if="showHeaderActions" class="actions flex items-center">
<button <button
v-if="canLeaveConversation && hasEndConversationEnabled" v-if="
canLeaveConversation &&
hasEndConversationEnabled &&
showEndConversationButton
"
class="button transparent compact" class="button transparent compact"
:title="$t('END_CONVERSATION')" :title="$t('END_CONVERSATION')"
@click="resolveConversation" @click="resolveConversation"
@@ -56,6 +60,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showEndConversationButton: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({

View File

@@ -1,18 +1,18 @@
<template> <template>
<div class="px-5"> <div class="p-4 shadow rounded-md bg-white dark:bg-slate-700">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div <div
class="max-w-xs" class="max-w-xs"
:class="$dm('text-black-700', 'dark:text-slate-50')" :class="$dm('text-slate-700', 'dark:text-slate-50')"
> >
<div class="text-base leading-5 font-medium mb-1"> <div class="text-sm font-medium mb-1">
{{ {{
isOnline isOnline
? $t('TEAM_AVAILABILITY.ONLINE') ? $t('TEAM_AVAILABILITY.ONLINE')
: $t('TEAM_AVAILABILITY.OFFLINE') : $t('TEAM_AVAILABILITY.OFFLINE')
}} }}
</div> </div>
<div class="text-xs leading-3 mt-1"> <div class="text-xs mt-1">
{{ replyWaitMessage }} {{ replyWaitMessage }}
</div> </div>
</div> </div>

View File

@@ -1,14 +1,16 @@
<template> <template>
<div <div
class="w-full h-full flex flex-col" class="w-full h-full flex flex-col relative bg-slate-50 dark:bg-slate-800"
:class="$dm('bg-slate-50', 'dark:bg-slate-800')" :class="{ 'overflow-auto': isOnHomeView }"
:style="portal ? { backgroundColor: backgroundColor } : {}"
@keydown.esc="closeWindow" @keydown.esc="closeWindow"
> >
<div <div
class="header-wrap" class="header-wrap sticky top-0 z-40"
:class="{ :class="{
expanded: !isHeaderCollapsed, expanded: !isHeaderCollapsed,
collapsed: isHeaderCollapsed, collapsed: isHeaderCollapsed,
'custom-header-shadow': (isOnHomeView && !portal) || !isOnArticleViewer,
}" }"
> >
<transition <transition
@@ -25,6 +27,7 @@
:intro-body="channelConfig.welcomeTagline" :intro-body="channelConfig.welcomeTagline"
:avatar-url="channelConfig.avatarUrl" :avatar-url="channelConfig.avatarUrl"
:show-popout-button="appConfig.showPopoutButton" :show-popout-button="appConfig.showPopoutButton"
:show-bg="!!portal"
/> />
<chat-header <chat-header
v-if="isHeaderCollapsed" v-if="isHeaderCollapsed"
@@ -32,6 +35,7 @@
:avatar-url="channelConfig.avatarUrl" :avatar-url="channelConfig.avatarUrl"
:show-popout-button="appConfig.showPopoutButton" :show-popout-button="appConfig.showPopoutButton"
:available-agents="availableAgents" :available-agents="availableAgents"
:show-back-button="showBackButton"
/> />
</transition> </transition>
</div> </div>
@@ -46,7 +50,7 @@
> >
<router-view /> <router-view />
</transition> </transition>
<branding :disable-branding="disableBranding" /> <branding v-if="!isOnArticleViewer" :disable-branding="disableBranding" />
</div> </div>
</template> </template>
<script> <script>
@@ -75,20 +79,42 @@ export default {
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
availableAgents: 'agent/availableAgents',
appConfig: 'appConfig/getAppConfig', appConfig: 'appConfig/getAppConfig',
availableAgents: 'agent/availableAgents',
widgetColor: 'appConfig/getWidgetColor',
}), }),
portal() {
return window.chatwootWebChannel.portal;
},
isHeaderCollapsed() { isHeaderCollapsed() {
if (!this.hasIntroText) { if (!this.hasIntroText) {
return true; return true;
} }
return this.$route.name !== 'home'; return !this.isOnHomeView;
},
backgroundColor() {
const color = this.widgetColor.replace('#', '');
const r = parseInt(color.slice(0, 2), 16);
const g = parseInt(color.slice(2, 4), 16);
const b = parseInt(color.slice(4, 6), 16);
return `rgba(${r},${g},${b}, 0.02)`;
}, },
hasIntroText() { hasIntroText() {
return ( return (
this.channelConfig.welcomeTitle || this.channelConfig.welcomeTagline this.channelConfig.welcomeTitle || this.channelConfig.welcomeTagline
); );
}, },
showBackButton() {
return ['article-viewer', 'messages', 'prechat-form'].includes(
this.$route.name
);
},
isOnArticleViewer() {
return ['article-viewer'].includes(this.$route.name);
},
isOnHomeView() {
return ['home'].includes(this.$route.name);
},
}, },
methods: { methods: {
closeWindow() { closeWindow() {
@@ -102,11 +128,13 @@ export default {
@import '~widget/assets/scss/variables'; @import '~widget/assets/scss/variables';
@import '~widget/assets/scss/mixins'; @import '~widget/assets/scss/mixins';
.custom-header-shadow {
@include shadow-large;
}
.header-wrap { .header-wrap {
flex-shrink: 0; flex-shrink: 0;
transition: max-height 300ms; transition: max-height 300ms;
z-index: 99;
@include shadow-large;
&.expanded { &.expanded {
max-height: 16rem; max-height: 16rem;

View File

@@ -108,6 +108,7 @@
}, },
"PORTAL": { "PORTAL": {
"POPULAR_ARTICLES": "Popular Articles", "POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles" "VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
} }
} }

View File

@@ -36,6 +36,12 @@ export default new Router({
name: 'messages', name: 'messages',
component: () => import('./views/Messages.vue'), component: () => import('./views/Messages.vue'),
}, },
{
path: '/article',
name: 'article-viewer',
props: true,
component: () => import('./views/ArticleViewer.vue'),
},
], ],
}, },
], ],

View File

@@ -23,7 +23,6 @@ export const actions = {
try { try {
const { data } = await getMostReadArticles(slug, locale); const { data } = await getMostReadArticles(slug, locale);
const { payload = [] } = data; const { payload = [] } = data;
if (payload.length) { if (payload.length) {
commit('setArticles', payload); commit('setArticles', payload);
} }

View File

@@ -0,0 +1,34 @@
<template>
<div class="bg-white h-full">
<iframe-loader :url="link" />
</div>
</template>
<script>
import { IFrameHelper } from 'widget/helpers/utils';
import IframeLoader from 'shared/components/IframeLoader';
export default {
name: 'Campaigns',
components: {
IframeLoader,
},
props: {
link: {
type: String,
default: '',
},
},
methods: {
closeFullView() {
if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({
event: 'setCampaignReadOn',
});
IFrameHelper.sendMessage({ event: 'toggleBubble' });
bus.$emit('snooze-campaigns');
}
},
},
};
</script>

View File

@@ -1,24 +1,54 @@
<template> <template>
<div class="flex flex-1 flex-col justify-end"> <div
<div class="flex flex-1 overflow-auto"> class="z-50 rounded-md border-t border-slate-50 w-full flex flex-1 flex-col justify-end"
<!-- Load Conversation List Components Here --> :class="{ 'pb-2': showArticles }"
>
<div v-if="false" class="px-4 py-2 w-full">
<div class="p-4 rounded-md bg-white dark:bg-slate-700 shadow w-full">
<article-hero
v-if="
!articleUiFlags.isFetching &&
!articleUiFlags.isError &&
popularArticles.length
"
:articles="popularArticles"
@view="openArticleInArticleViewer"
@view-all="viewAllArticles"
/>
<div
v-if="articleUiFlags.isFetching"
class="flex flex-col items-center justify-center py-8"
>
<div class="inline-block p-4 rounded-lg bg-slate-200">
<spinner size="small" />
</div>
</div>
</div>
</div>
<div class="px-4 pt-2 w-full sticky bottom-4">
<team-availability
:available-agents="availableAgents"
:has-conversation="!!conversationSize"
@start-conversation="startConversation"
/>
</div> </div>
<team-availability
:available-agents="availableAgents"
:has-conversation="!!conversationSize"
@start-conversation="startConversation"
/>
</div> </div>
</template> </template>
<script> <script>
import configMixin from '../mixins/configMixin';
import TeamAvailability from 'widget/components/TeamAvailability'; import TeamAvailability from 'widget/components/TeamAvailability';
import ArticleHero from 'widget/components/ArticleHero';
import Spinner from 'shared/components/Spinner.vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin';
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
Spinner,
ArticleHero,
TeamAvailability, TeamAvailability,
}, },
mixins: [configMixin, routerMixin], mixins: [configMixin, routerMixin],
@@ -32,15 +62,31 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {};
},
computed: { computed: {
...mapGetters({ ...mapGetters({
availableAgents: 'agent/availableAgents', availableAgents: 'agent/availableAgents',
activeCampaign: 'campaign/getActiveCampaign', activeCampaign: 'campaign/getActiveCampaign',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
popularArticles: 'article/popularArticles',
articleUiFlags: 'article/uiFlags',
}), }),
portal() {
return window.chatwootWebChannel.portal;
},
showArticles() {
return (
this.portal &&
(this.articleUiFlags.isFetching || this.popularArticles.length)
);
},
},
mounted() {
if (this.portal && this.popularArticles.length === 0) {
this.$store.dispatch('article/fetch', {
slug: this.portal.slug,
locale: 'en',
});
}
}, },
methods: { methods: {
startConversation() { startConversation() {
@@ -49,6 +95,19 @@ export default {
} }
return this.replaceRoute('messages'); return this.replaceRoute('messages');
}, },
openArticleInArticleViewer(link) {
this.$router.push({
name: 'article-viewer',
params: { link: `${link}?show_plain_layout=true` },
});
},
viewAllArticles() {
const {
portal: { slug },
locale = 'en',
} = window.chatwootWebChannel;
this.openArticleInArticleViewer(`/hc/${slug}/${locale}`);
},
}, },
}; };
</script> </script>

View File

@@ -38,3 +38,5 @@ json.associated_articles do
end end
end end
end end
json.link "hc/#{article.portal.slug}/articles/#{article.slug}"

View File

@@ -14,6 +14,7 @@
welcomeTagline: '<%= @web_widget.welcome_tagline %>', welcomeTagline: '<%= @web_widget.welcome_tagline %>',
welcomeTitle: '<%= @web_widget.welcome_title %>', welcomeTitle: '<%= @web_widget.welcome_title %>',
widgetColor: '<%= @web_widget.widget_color %>', widgetColor: '<%= @web_widget.widget_color %>',
portal: <%= @web_widget.inbox.portal.to_json.html_safe %>,
enabledFeatures: <%= @web_widget.selected_feature_flags.to_json.html_safe %>, enabledFeatures: <%= @web_widget.selected_feature_flags.to_json.html_safe %>,
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>, enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
replyTime: '<%= @web_widget.reply_time %>', replyTime: '<%= @web_widget.reply_time %>',

View File

@@ -12,8 +12,8 @@ module.exports = {
], ],
theme: { theme: {
fontSize: { fontSize: {
xxs: '0.625rem',
...defaultTheme.fontSize, ...defaultTheme.fontSize,
xxs: '0.625rem',
}, },
colors: { colors: {
transparent: 'transparent', transparent: 'transparent',
@@ -113,6 +113,7 @@ module.exports = {
body: '#2f3b49', body: '#2f3b49',
}, },
keyframes: { keyframes: {
...defaultTheme.keyframes,
wiggle: { wiggle: {
'0%': { transform: 'translateX(0)' }, '0%': { transform: 'translateX(0)' },
'15%': { transform: 'translateX(0.375rem)' }, '15%': { transform: 'translateX(0.375rem)' },
@@ -125,6 +126,7 @@ module.exports = {
}, },
}, },
animation: { animation: {
...defaultTheme.animation,
wiggle: 'wiggle 0.5s ease-in-out', wiggle: 'wiggle 0.5s ease-in-out',
}, },
}, },