Compare commits

...
Author SHA1 Message Date
iamsivin 5ca0f7d877 chore: Merge fix 2025-03-04 10:21:57 +05:30
Sivin VargheseandGitHub bcf62cd296 Merge branch 'develop' into feat/widget-v2 2025-03-04 10:20:34 +05:30
Pranav ecb99e0e8c Migrate widgetPortalArticlesToPinia 2025-01-12 12:03:35 -08:00
Pranav bb632d563c Add WorkingHoursHelper 2025-01-12 02:45:40 -08:00
Pranav 4d9f1a7e1a Update team availability 2025-01-12 01:11:27 -08:00
Pranav 1e1b1f6844 Rearranage components 2025-01-12 00:37:32 -08:00
Pranav 33dc2ca2ee Cleanup components 2025-01-12 00:33:01 -08:00
Pranav c47ca2f66c Simplify components 2025-01-12 00:09:32 -08:00
Pranav dcf0e62785 Fix article loading 2025-01-11 23:52:29 -08:00
Pranav 4977b60a10 Update design 2025-01-11 23:47:59 -08:00
Pranav 3e5fac66c2 Move to composition API 2025-01-11 23:15:10 -08:00
Pranav 502b950f83 Fix articles endpoint performance 2025-01-11 23:08:37 -08:00
38 changed files with 925 additions and 543 deletions
+3
View File
@@ -10,6 +10,7 @@ import router from '../widget/router';
import { directive as onClickaway } from 'vue3-click-away'; import { directive as onClickaway } from 'vue3-click-away';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer'; import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { plugin, defaultConfig } from '@formkit/vue'; import { plugin, defaultConfig } from '@formkit/vue';
import { createPinia } from 'pinia';
import { import {
startsWithPlus, startsWithPlus,
@@ -21,9 +22,11 @@ const i18n = createI18n({
locale: 'en', locale: 'en',
messages: i18nMessages, messages: i18nMessages,
}); });
const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app.use(i18n); app.use(i18n);
app.use(pinia);
app.use(store); app.use(store);
app.use(router); app.use(router);
app.use(VueDOMPurifyHTML, domPurifyConfig); app.use(VueDOMPurifyHTML, domPurifyConfig);
@@ -1,3 +1,19 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Thin.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Light.woff2') format('woff2');
}
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
@@ -6,6 +22,14 @@
src: url('shared/assets/fonts/Inter/Inter-Regular.woff2') format('woff2'); src: url('shared/assets/fonts/Inter/Inter-Regular.woff2') format('woff2');
} }
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Italic.woff2') format('woff2');
}
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
@@ -13,3 +37,19 @@
font-display: swap; font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Medium.woff2') format('woff2'); src: url('shared/assets/fonts/Inter/Inter-Medium.woff2') format('woff2');
} }
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Bold.woff2') format('woff2');
}
+2 -2
View File
@@ -335,14 +335,14 @@ export default {
<template> <template>
<div <div
v-if="!conversationSize && isFetchingList" v-if="!conversationSize && isFetchingList"
class="flex items-center justify-center flex-1 h-full bg-black-25" class="flex items-center justify-center flex-1 h-full max-w-96 mx-auto"
:class="{ dark: prefersDarkMode }" :class="{ dark: prefersDarkMode }"
> >
<Spinner size="" /> <Spinner size="" />
</div> </div>
<div <div
v-else v-else
class="flex flex-col justify-end h-full" class="flex flex-col justify-end h-full max-w-[600px] mx-auto"
:class="{ :class="{
'is-mobile': isMobile, 'is-mobile': isMobile,
'is-widget-right': isRightAligned, 'is-widget-right': isRightAligned,
-7
View File
@@ -1,7 +0,0 @@
import endPoints from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
export const getMostReadArticles = async (slug, locale) => {
const urlData = endPoints.getMostReadArticles(slug, locale);
return API.get(urlData.url, { params: urlData.params });
};
-10
View File
@@ -97,15 +97,6 @@ const triggerCampaign = ({ websiteToken, campaignId, customAttributes }) => ({
}, },
}); });
const getMostReadArticles = (slug, locale) => ({
url: `/hc/${slug}/${locale}/articles.json`,
params: {
page: 1,
sort: 'views',
status: 1,
},
});
export default { export default {
createConversation, createConversation,
sendMessage, sendMessage,
@@ -115,5 +106,4 @@ export default {
getAvailableAgents, getAvailableAgents,
getCampaigns, getCampaigns,
triggerCampaign, triggerCampaign,
getMostReadArticles,
}; };
@@ -14,7 +14,6 @@ $input-height: $space-two * 2;
box-sizing: border-box; box-sizing: border-box;
color: $color-body; color: $color-body;
display: block; display: block;
font-family: $font-family;
font-size: $font-size-medium; font-size: $font-size-medium;
height: $input-height; height: $input-height;
line-height: 1.5; line-height: 1.5;
@@ -69,7 +68,6 @@ $input-height: $space-two * 2;
// Form element: Textarea // Form element: Textarea
textarea.form-input { textarea.form-input {
font-family: $font-family;
@include placeholder { @include placeholder {
color: $color-light-gray; color: $color-light-gray;
@@ -72,16 +72,5 @@ $line-height: 1;
$footer-height: 11.2rem; $footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10; $header-expanded-height: $space-medium * 10;
$font-family: 'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif;
// Break points // Break points
$break-point-medium: 667px; $break-point-medium: 667px;
+210 -8
View File
@@ -15,10 +15,7 @@
html, html,
body { body {
font-family: $font-family; @apply antialiased h-full font-inter bg-n-background;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
} }
.is-mobile { .is-mobile {
@@ -43,10 +40,6 @@ body {
} }
} }
.cursor-pointer {
cursor: pointer;
}
.message-content { .message-content {
ul { ul {
list-style: disc; list-style: disc;
@@ -82,3 +75,212 @@ body {
} }
} }
} }
@layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -1,15 +0,0 @@
<template>
<div class="py-4 space-y-4 bg-white dark:bg-slate-700">
<div class="space-y-2 animate-pulse">
<div class="h-6 bg-slate-100 dark:bg-slate-500 rounded w-2/5" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded w-1/5" />
</div>
</div>
</template>
@@ -1,54 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import ArticleList from './ArticleList.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon, ArticleList },
props: {
title: {
type: String,
default: '',
},
articles: {
type: Array,
default: () => [],
},
},
emits: ['view', 'viewAll'],
computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
},
methods: {
onArticleClick(link) {
this.$emit('view', link);
},
},
};
</script>
<template>
<div>
<h3 class="mb-0 text-sm font-medium text-slate-800 dark:text-slate-50">
{{ title }}
</h3>
<ArticleList :articles="articles" @select-article="onArticleClick" />
<button
class="inline-flex items-center justify-between px-2 py-1 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800 see-articles"
:style="{ color: widgetColor }"
@click="$emit('viewAll')"
>
<span class="pr-2 text-sm">{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
<FluentIcon icon="arrow-right" size="14" />
</button>
</div>
</template>
<style lang="scss" scoped>
.see-articles {
color: var(--brand-textButtonClear);
svg {
color: var(--brand-textButtonClear);
}
}
</style>
@@ -1,27 +0,0 @@
<script>
import CategoryCard from './ArticleCategoryCard.vue';
export default {
components: { CategoryCard },
props: {
articles: {
type: Array,
default: () => [],
},
},
emits: ['view', 'viewAll'],
methods: {
onArticleClick(link) {
this.$emit('view', link);
},
},
};
</script>
<template>
<CategoryCard
:title="$t('PORTAL.POPULAR_ARTICLES')"
:articles="articles.slice(0, 6)"
@view-all="$emit('viewAll')"
@view="onArticleClick"
/>
</template>
@@ -1,36 +0,0 @@
<script>
import ArticleListItem from './ArticleListItem.vue';
export default {
components: {
ArticleListItem,
},
props: {
articles: {
type: Array,
default: () => [],
},
},
emits: ['selectArticle'],
data() {
return {};
},
methods: {
onClick(link) {
this.$emit('selectArticle', link);
},
},
};
</script>
<template>
<ul role="list" class="py-2">
<ArticleListItem
v-for="article in articles"
:key="article.slug"
:link="article.link"
:title="article.title"
@select-article="onClick"
/>
</ul>
</template>
@@ -1,41 +0,0 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon },
props: {
link: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
emits: ['selectArticle'],
data() {
return {};
},
methods: {
onClick() {
this.$emit('selectArticle', this.link);
},
},
};
</script>
<template>
<li
class="py-1 flex items-center justify-between -mx-1 px-1 hover:bg-slate-25 dark:hover:bg-slate-600 rounded cursor-pointer text-slate-700 dark:text-slate-50 dark:hover:text-slate-25 hover:text-slate-900"
role="button"
@click="onClick"
>
<button class="underline-offset-2 text-sm leading-6 text-left">
{{ title }}
</button>
<span class="pl-1 arrow">
<FluentIcon icon="arrow-right" size="14" />
</span>
</li>
</template>
@@ -1,52 +0,0 @@
<script>
import { debounce } from '@chatwoot/utils';
export default {
props: {
placeholder: {
type: String,
default: '',
},
},
emits: ['search'],
data() {
return {
searchQuery: '',
};
},
methods: {
handleInput: debounce(
() => {
this.$emit('search', this.searchQuery);
},
500,
true
),
},
};
</script>
<template>
<div class="relative flex items-center">
<div
class="absolute inset-y-0 left-0 flex items-center px-2 py-2 text-slate-500"
>
<fluent-icon icon="search" size="14" />
</div>
<input
id="search"
v-model="searchQuery"
:placeholder="placeholder"
type="text"
name="search"
class="block w-full h-8 px-2 pl-6 pr-1 text-sm border rounded-md focus-visible:outline-none text-slate-800 border-slate-100 bg-slate-75 placeholder:text-slate-400 focus:ring focus:border-woot-500 focus:ring-woot-200 hover:border-woot-200"
@input="handleInput"
/>
<div class="absolute inset-y-0 right-0 flex py-1.5 pr-1.5">
<kbd
class="inline-flex items-center px-1 font-sans border rounded border-slate-200 text-xxs text-slate-400"
>
{{ '⌘K' }}
</kbd>
</div>
</div>
</template>
@@ -1,27 +0,0 @@
<script>
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
export default {
name: 'AvailableAgents',
components: { GroupedAvatars },
props: {
agents: {
type: Array,
default: () => [],
},
},
computed: {
users() {
return this.agents.slice(0, 4).map(agent => ({
id: agent.id,
avatar: agent.avatar_url,
name: agent.name,
}));
},
},
};
</script>
<template>
<GroupedAvatars :users="users" />
</template>
@@ -1,45 +1,36 @@
<script> <script setup>
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import { useDarkMode } from 'widget/composables/useDarkMode'; import { computed } from 'vue';
export default { const props = defineProps({
name: 'ChatHeaderExpanded', avatarUrl: {
components: { type: String,
HeaderActions, default: '',
}, },
props: { introHeading: {
avatarUrl: { type: String,
type: String, default: '',
default: '',
},
introHeading: {
type: String,
default: '',
},
introBody: {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
}, },
setup() { introBody: {
const { getThemeClass } = useDarkMode(); type: String,
return { getThemeClass }; default: '',
}, },
}; showPopoutButton: {
type: Boolean,
default: false,
},
});
const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end',
]);
</script> </script>
<template> <template>
<header <header
class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent" class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent"
> >
<div <div class="flex items-start" :class="containerClasses">
class="flex items-start"
:class="[avatarUrl ? 'justify-between' : 'justify-end']"
>
<img <img
v-if="avatarUrl" v-if="avatarUrl"
class="h-12 rounded-full" class="h-12 rounded-full"
@@ -53,13 +44,11 @@ export default {
</div> </div>
<h2 <h2
v-dompurify-html="introHeading" v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium" class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
/> />
<p <p
v-dompurify-html="introBody" v-dompurify-html="introBody"
class="text-base leading-normal" class="text-lg leading-normal text-n-slate-11"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
/> />
</header> </header>
</template> </template>
@@ -1,26 +1,28 @@
<script> <script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { defineProps, computed } from 'vue';
export default { const props = defineProps({
name: 'GroupedAvatars', users: {
components: { Thumbnail }, type: Array,
props: { default: () => [],
users: {
type: Array,
default: () => [],
},
}, },
}; limit: {
type: Number,
default: 4,
},
});
const usersToDisplay = computed(() => props.users.slice(0, props.limit));
</script> </script>
<template> <template>
<div class="flex"> <div class="flex">
<span <span
v-for="(user, index) in users" v-for="(user, index) in usersToDisplay"
:key="user.id" :key="user.id"
:class="`${ :class="index ? '-ml-4' : ''"
index ? '-ml-4' : '' class="inline-block rounded-full text-white shadow-solid"
} inline-block rounded-full text-white shadow-solid`"
> >
<Thumbnail <Thumbnail
size="36px" size="36px"
@@ -2,24 +2,22 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime'; import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability'; import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { IFrameHelper } from 'widget/helpers/utils'; import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
export default { export default {
name: 'TeamAvailability', name: 'TeamAvailability',
components: { components: {
AvailableAgents, GroupedAvatars,
FluentIcon,
}, },
mixins: [configMixin, nextAvailabilityTime, availabilityMixin], mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
props: { props: {
availableAgents: { availableAgents: {
type: Array, type: Array,
default: () => {}, default: () => { },
}, },
hasConversation: { hasConversation: {
type: Boolean, type: Boolean,
@@ -35,6 +33,13 @@ export default {
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },
agentAvatars() {
return this.availableAgents.map(agent => ({
name: agent.name,
avatar: agent.avatar_url,
id: agent.id,
}));
},
isOnline() { isOnline() {
const { workingHoursEnabled } = this.channelConfig; const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0; const anyAgentOnline = this.availableAgents.length > 0;
@@ -61,35 +66,37 @@ export default {
</script> </script>
<template> <template>
<div class="p-4 bg-white rounded-md shadow-sm dark:bg-slate-700"> <div
<div class="flex items-center justify-between"> class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-5 py-4"
<div class=""> >
<div class="text-sm font-medium text-slate-700 dark:text-slate-50"> <div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12">
{{ {{
isOnline isOnline
? $t('TEAM_AVAILABILITY.ONLINE') ? $t('TEAM_AVAILABILITY.ONLINE')
: $t('TEAM_AVAILABILITY.OFFLINE') : $t('TEAM_AVAILABILITY.OFFLINE')
}} }}
</div> </div>
<div class="mt-1 text-sm text-slate-500 dark:text-slate-100"> <div class="text-n-slate-11">
{{ replyWaitMessage }} {{ replyWaitMessage }}
</div> </div>
</div> </div>
<AvailableAgents v-if="isOnline" :agents="availableAgents" /> <GroupedAvatars v-if="isOnline" :users="agentAvatars" />
</div> </div>
<button <button
class="inline-flex items-center justify-between px-2 py-1 mt-2 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800" class="inline-flex items-center font-medium text-n-slate-12"
:style="{ color: widgetColor }" :style="{ color: widgetColor }"
@click="startConversation" @click="startConversation"
> >
<span class="pr-2 text-sm"> <span class="pr-1">
{{ {{
hasConversation hasConversation
? $t('CONTINUE_CONVERSATION') ? $t('CONTINUE_CONVERSATION')
: $t('START_CONVERSATION') : $t('START_CONVERSATION')
}} }}
</span> </span>
<FluentIcon icon="arrow-right" size="14" /> <i class="i-lucide-chevron-right text-lg" />
</button> </button>
</div> </div>
</template> </template>
@@ -103,13 +103,13 @@ export default {
<template> <template>
<div <div
class="w-full h-full bg-slate-25 dark:bg-slate-800" class="w-full h-full"
:class="{ 'overflow-auto': isOnHomeView }" :class="{ 'overflow-auto': isOnHomeView }"
@keydown.esc="closeWindow" @keydown.esc="closeWindow"
> >
<div class="relative flex flex-col h-full"> <div class="relative flex flex-col h-full">
<div <div
class="sticky top-0 z-40 transition-all header-wrap" class=""
:class="{ :class="{
expanded: !isHeaderCollapsed, expanded: !isHeaderCollapsed,
collapsed: isHeaderCollapsed, collapsed: isHeaderCollapsed,
@@ -148,21 +148,4 @@ export default {
.custom-header-shadow { .custom-header-shadow {
@include shadow-large; @include shadow-large;
} }
.header-wrap {
flex-shrink: 0;
transition: max-height 100ms;
&.expanded {
max-height: 16rem;
}
&.collapsed {
max-height: 4.5rem;
}
@media only screen and (min-device-width: 320px) and (max-device-width: 667px) {
border-radius: 0;
}
}
</style> </style>
@@ -0,0 +1,44 @@
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
import ArticleListItem from './ArticleListItem.vue';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
articles: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['view', 'viewAll']);
const widgetColor = useMapGetter('appConfig/getWidgetColor');
const articlesToDisplay = computed(() => props.articles.slice(0, 6));
const onArticleClick = link => {
emit('view', link);
};
</script>
<template>
<div class="flex flex-col gap-3 pb-4">
<h3 class="font-medium text-n-slate-12">
{{ $t('PORTAL.POPULAR_ARTICLES') }}
</h3>
<div class="flex flex-col gap-4">
<ArticleListItem
v-for="article in articlesToDisplay"
:key="article.slug"
:link="article.link"
:title="article.title"
@select-article="onArticleClick"
/>
</div>
<button
class="font-medium tracking-wide inline-flex"
:style="{ color: widgetColor }"
@click="$emit('viewAll')"
>
<span>{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
</button>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { computed, onMounted } from 'vue';
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
import { useArticleStore } from 'widget/stores/articleStore';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useDarkMode } from 'widget/composables/useDarkMode';
const store = useArticleStore();
const router = useRouter();
const { prefersDarkMode } = useDarkMode();
const portal = computed(() => window.chatwootWebChannel.portal);
const locale = computed(() => {
const { locale: selectedLocale } = useI18n();
const {
allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
});
const fetchArticles = () => {
if (portal.value && !store.getRecords.length) {
store.index({ slug: portal.value.slug, locale: locale.value });
}
};
const openArticleInArticleViewer = link => {
let linkToOpen = `${link}?show_plain_layout=true`;
if (prefersDarkMode) {
linkToOpen = `${linkToOpen}&theme=dark`;
}
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
};
const viewAllArticles = () => {
const {
portal: { slug },
} = window.chatwootWebChannel;
openArticleInArticleViewer(`/hc/${slug}/${locale.value}`);
};
const hasArticles = computed(
() => !store.getUIFlags.isFetching && !!store.getRecords.length
);
onMounted(() => fetchArticles());
</script>
<template>
<div
v-if="portal"
class="w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-5 py-4"
>
<ArticleBlock
v-if="hasArticles"
:articles="store.getRecords"
@view="openArticleInArticleViewer"
@view-all="viewAllArticles"
/>
<ArticleCardSkeletonLoader v-else />
</div>
<div v-else />
</template>
@@ -0,0 +1,33 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
link: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectArticle']);
const onClick = () => {
emit('selectArticle', props.link);
};
</script>
<template>
<div
class="flex items-center justify-between rounded cursor-pointer text-n-slate-11 hover:text-n-slate-12 gap-2"
role="button"
@click="onClick"
>
<button class="underline-offset-2 leading-6 text-left">
{{ title }}
</button>
<span class="i-lucide-chevron-right text-base shrink-0" />
</div>
</template>
@@ -0,0 +1,15 @@
<template>
<div class="py-4 space-y-4">
<div class="space-y-2 animate-pulse">
<div class="h-6 bg-n-slate-7 rounded w-2/5" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-n-slate-7 rounded" />
<div class="h-4 bg-n-slate-7 rounded" />
<div class="h-4 bg-n-slate-7 rounded" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-n-slate-7 rounded w-1/5" />
</div>
</div>
</template>
@@ -0,0 +1,94 @@
const organizeByDay = workingHoursData => {
return workingHoursData.reduce((acc, schedule) => {
acc[schedule.dayOfWeek] = schedule;
return acc;
}, {});
};
const toMinutes = (hours, minutes = 0) => {
return hours * 60 + (minutes || 0);
};
const calculateMinutesUntilOpen = (
schedule,
currentTimeInMinutes,
dayOffset
) => {
if (!schedule || schedule.closedAllDay) {
return null;
}
let openHour = schedule.openHour;
let openMinutes = schedule.openMinutes;
if (schedule.openAllDay) {
openHour = 0;
openMinutes = 0;
}
const openTimeInMinutes = toMinutes(openHour, openMinutes);
if (dayOffset === 0) {
if (currentTimeInMinutes < openTimeInMinutes) {
return openTimeInMinutes - currentTimeInMinutes;
}
return null;
}
return (
toMinutes(24, 0) * dayOffset + openTimeInMinutes - currentTimeInMinutes
);
};
class WorkingHours {
constructor(workingHoursData) {
this.workingHoursByDay = organizeByDay(workingHoursData);
}
isOpen(date) {
const schedule = this.workingHoursByDay[date.getDay()];
if (!schedule || schedule.closedAllDay) {
return false;
}
if (schedule.openAllDay) {
return true;
}
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
const openMinutes = toMinutes(schedule.openHour, schedule.openMinutes);
const closeMinutes = toMinutes(schedule.closeHour, schedule.closeMinutes);
return currentMinutes >= openMinutes && currentMinutes <= closeMinutes;
}
openInMinutes(date) {
if (this.isOpen(date)) {
return 0;
}
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
for (let i = 0; i < 7; i += 1) {
const checkDate = new Date(date);
checkDate.setDate(date.getDate() + i);
const schedule = this.workingHoursByDay[checkDate.getDay()];
const minutesUntilOpen = calculateMinutesUntilOpen(
schedule,
currentMinutes,
i
);
if (minutesUntilOpen !== null) {
return minutesUntilOpen;
}
}
return -1;
}
}
export default WorkingHours;
@@ -0,0 +1,57 @@
export default [
{
dayOfWeek: 0,
openHour: 10,
openMinutes: 0,
closeHour: 16,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Sunday
{
dayOfWeek: 1,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Monday
{
dayOfWeek: 2,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Tuesday
{
dayOfWeek: 3,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Wednesday
{
dayOfWeek: 4,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Thursday
{
dayOfWeek: 5,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
openAllDay: false,
}, // Friday
{ dayOfWeek: 6, closedAllDay: true, openAllDay: false }, // Saturday
];
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import WorkingHours from '../WorkingHoursHelper';
import workingHoursData from './WorkingHourFixtures';
describe('WorkingHours', () => {
describe('isOpen', () => {
it('returns true during business hours', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(12, 0, 0); // 12:00
expect(workingHours.isOpen(date)).toBe(true);
});
it('returns false before opening hours', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(8, 0, 0); // 8:00
expect(workingHours.isOpen(date)).toBe(false);
});
it('returns false after closing hours', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(18, 0, 0); // 18:00
expect(workingHours.isOpen(date)).toBe(false);
});
it('returns false on closed days', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 4); // Saturday
date.setHours(12, 0, 0); // 12:00
expect(workingHours.isOpen(date)).toBe(false);
});
it('returns true for openAllDay', () => {
const customData = [...workingHoursData];
customData[1] = { ...customData[1], openAllDay: true };
const workingHours = new WorkingHours(customData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(22, 0, 0);
expect(workingHours.isOpen(date)).toBe(true);
});
});
describe('openInMinutes', () => {
it('returns 0 when currently open', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(12, 0, 0); // 12:00
expect(workingHours.openInMinutes(date)).toBe(0);
});
it('calculates minutes until opening same day', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(8, 0, 0); // 8:00
expect(workingHours.openInMinutes(date)).toBe(60); // 60 minutes until 9:00
});
it('calculates minutes until opening next day', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 6); // Monday
date.setHours(23, 0, 0); // 23:00
// Tuesday opens at 9:00, so it's 10 hours (600 minutes) away
expect(workingHours.openInMinutes(date)).toBe(600);
});
it('handles closed days correctly', () => {
const workingHours = new WorkingHours(workingHoursData);
const date = new Date(2025, 0, 4); // Saturday
date.setHours(12, 0, 0); // 12:00
// Should find Sunday's opening at 10:00
const expectedMinutes = (24 - 12) * 60 + 10 * 60; // Remaining hours today + hours until opening tomorrow
expect(workingHours.openInMinutes(date)).toBe(expectedMinutes);
});
it('returns -1 if no opening time found within a week', () => {
const closedWeek = workingHoursData.map(day => ({
...day,
closedAllDay: true,
}));
const workingHours = new WorkingHours(closedWeek);
const date = new Date(2025, 0, 6); // Monday
date.setHours(12, 0, 0); // 12:00
expect(workingHours.openInMinutes(date)).toBe(-1);
});
it('returns -1 if no working hours are provided', () => {
const workingHours = new WorkingHours([]);
const date = new Date(2025, 0, 6); // Monday
date.setHours(12, 0, 0); // 12:00
expect(workingHours.openInMinutes(date)).toBe(-1);
});
});
});
+12 -6
View File
@@ -1,5 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import ViewWithHeader from './components/layouts/ViewWithHeader.vue'; import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
import UnreadMessages from './views/UnreadMessages.vue';
import Campaigns from './views/Campaigns.vue';
import Home from './views/Home.vue';
import PreChatForm from './views/PreChatForm.vue';
import Messages from './views/Messages.vue';
import ArticleViewer from './views/ArticleViewer.vue';
import store from './store'; import store from './store';
const router = createRouter({ const router = createRouter({
@@ -8,12 +14,12 @@ const router = createRouter({
{ {
path: '/unread-messages', path: '/unread-messages',
name: 'unread-messages', name: 'unread-messages',
component: () => import('./views/UnreadMessages.vue'), component: UnreadMessages,
}, },
{ {
path: '/campaigns', path: '/campaigns',
name: 'campaigns', name: 'campaigns',
component: () => import('./views/Campaigns.vue'), component: Campaigns,
}, },
{ {
path: '/', path: '/',
@@ -22,22 +28,22 @@ const router = createRouter({
{ {
path: '', path: '',
name: 'home', name: 'home',
component: () => import('./views/Home.vue'), component: Home,
}, },
{ {
path: '/prechat-form', path: '/prechat-form',
name: 'prechat-form', name: 'prechat-form',
component: () => import('./views/PreChatForm.vue'), component: PreChatForm,
}, },
{ {
path: '/messages', path: '/messages',
name: 'messages', name: 'messages',
component: () => import('./views/Messages.vue'), component: Messages,
}, },
{ {
path: '/article', path: '/article',
name: 'article-viewer', name: 'article-viewer',
component: () => import('./views/ArticleViewer.vue'), component: ArticleViewer,
}, },
], ],
}, },
-2
View File
@@ -10,7 +10,6 @@ import events from 'widget/store/modules/events';
import globalConfig from 'shared/store/globalConfig'; import globalConfig from 'shared/store/globalConfig';
import message from 'widget/store/modules/message'; import message from 'widget/store/modules/message';
import campaign from 'widget/store/modules/campaign'; import campaign from 'widget/store/modules/campaign';
import article from 'widget/store/modules/articles';
export default createStore({ export default createStore({
modules: { modules: {
@@ -24,6 +23,5 @@ export default createStore({
globalConfig, globalConfig,
message, message,
campaign, campaign,
article,
}, },
}); });
@@ -1,55 +0,0 @@
import { getMostReadArticles } from 'widget/api/article';
const state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
isFetching: false,
},
};
export const getters = {
uiFlags: $state => $state.uiFlags,
popularArticles: $state => $state.records,
};
export const actions = {
fetch: async ({ commit }, { slug, locale }) => {
commit('setIsFetching', true);
commit('setError', false);
try {
const { data } = await getMostReadArticles(slug, locale);
const { payload = [] } = data;
if (payload.length) {
commit('setArticles', payload);
}
} catch (error) {
commit('setError', true);
} finally {
commit('setIsFetching', false);
}
},
};
export const mutations = {
setArticles($state, data) {
$state.records = data;
},
setError($state, value) {
$state.uiFlags.isError = value;
},
setIsFetching($state, value) {
$state.uiFlags.isFetching = value;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,13 @@
import { API } from 'widget/helpers/axios';
export default {
index: params => {
const url = `/hc/${params.slug}/${params.locale}/articles.json`;
const urlParams = {
page: 1,
sort: 'views',
status: 1,
};
return API.get(url, { params: urlParams });
},
};
@@ -0,0 +1,6 @@
import articleAPI from './api/articleAPI';
import { createResourceStore } from './piniaStoreFactory';
export const useArticleStore = createResourceStore('articles', {
api: articleAPI,
});
@@ -0,0 +1,105 @@
import { defineStore } from 'pinia';
export const createResourceStore = (
storeName,
{ api, customGetters = {}, customActions = {} }
) => {
return defineStore(storeName, {
state: () => ({
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
error: null,
}),
getters: {
getRecords: state => state.records,
getUIFlags: state => state.uiFlags,
getError: state => state.error,
...customGetters,
},
actions: {
async index(params = {}) {
this.uiFlags.isFetching = true;
this.error = null;
try {
const {
data: { payload = [] },
} = await api.index(params);
this.records = [...payload];
} catch (error) {
this.error = error;
throw error;
} finally {
this.uiFlags.isFetching = false;
}
},
async create(payload) {
this.uiFlags.isCreating = true;
this.error = null;
try {
const { data } = await api.post(payload);
this.records.push(data.payload);
return data.payload;
} catch (error) {
this.error = error;
throw error;
} finally {
this.uiFlags.isCreating = false;
}
},
async update(id, payload) {
this.uiFlags.isUpdating = true;
this.error = null;
try {
const { data } = await api.put(id, payload);
const index = this.records.findIndex(record => record.id === id);
if (index !== -1) {
this.records[index] = data.payload;
}
return data.payload;
} catch (error) {
this.error = error;
throw error;
} finally {
this.uiFlags.isUpdating = false;
}
},
async delete(id) {
this.uiFlags.isDeleting = true;
this.error = null;
try {
await api.delete(id);
this.records = this.records.filter(record => record.id !== id);
} catch (error) {
this.error = error;
throw error;
} finally {
this.uiFlags.isDeleting = false;
}
},
reset() {
this.records = [];
this.error = null;
Object.keys(this.uiFlags).forEach(key => {
this.uiFlags[key] = false;
});
},
...customActions,
},
});
};
+12 -95
View File
@@ -1,69 +1,26 @@
<script> <script>
import TeamAvailability from 'widget/components/TeamAvailability.vue'; import TeamAvailability from 'widget/components/TeamAvailability.vue';
import ArticleHero from 'widget/components/ArticleHero.vue';
import ArticleCardSkeletonLoader from 'widget/components/ArticleCardSkeletonLoader.vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useDarkMode } from 'widget/composables/useDarkMode';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import ArticleContainer from '../components/pageComponents/Home/Article/ArticleContainer.vue';
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
ArticleHero, ArticleContainer,
TeamAvailability, TeamAvailability,
ArticleCardSkeletonLoader,
}, },
mixins: [configMixin, routerMixin], mixins: [configMixin, routerMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode };
},
computed: { computed: {
...mapGetters({ ...mapGetters({
availableAgents: 'agent/availableAgents', availableAgents: 'agent/availableAgents',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
unreadMessageCount: 'conversation/getUnreadMessageCount', unreadMessageCount: 'conversation/getUnreadMessageCount',
popularArticles: 'article/popularArticles',
articleUiFlags: 'article/uiFlags',
}), }),
widgetLocale() { },
return this.$i18n.locale || 'en';
},
portal() {
return window.chatwootWebChannel.portal;
},
showArticles() {
return (
this.portal &&
!this.articleUiFlags.isFetching &&
this.popularArticles.length
);
},
defaultLocale() {
const widgetLocale = this.widgetLocale;
const { allowed_locales: allowedLocales, default_locale: defaultLocale } =
this.portal.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(widgetLocale)) {
return widgetLocale;
}
return defaultLocale;
},
},
mounted() {
if (this.portal && this.popularArticles.length === 0) {
const locale = this.defaultLocale;
this.$store.dispatch('article/fetch', {
slug: this.portal.slug,
locale,
});
}
},
methods: { methods: {
startConversation() { startConversation() {
if (this.preChatFormEnabled && !this.conversationSize) { if (this.preChatFormEnabled && !this.conversationSize) {
@@ -71,59 +28,19 @@ export default {
} }
return this.replaceRoute('messages'); return this.replaceRoute('messages');
}, },
openArticleInArticleViewer(link) {
let linkToOpen = `${link}?show_plain_layout=true`;
const isDark = this.prefersDarkMode;
if (isDark) {
linkToOpen = `${linkToOpen}&theme=dark`;
}
this.$router.push({
name: 'article-viewer',
query: { link: linkToOpen },
});
},
viewAllArticles() {
const locale = this.defaultLocale;
const {
portal: { slug },
} = window.chatwootWebChannel;
this.openArticleInArticleViewer(`/hc/${slug}/${locale}`);
},
}, },
}; };
</script> </script>
<template> <template>
<div <div class="z-50 flex flex-col flex-1 w-full p-4 gap-4">
class="z-50 flex flex-col flex-1 w-full rounded-md" <TeamAvailability
:class="{ 'pb-2': showArticles, 'justify-end': !showArticles }" :available-agents="availableAgents"
> :has-conversation="!!conversationSize"
<div class="w-full px-4 pt-4"> :unread-count="unreadMessageCount"
<TeamAvailability @start-conversation="startConversation"
:available-agents="availableAgents" />
:has-conversation="!!conversationSize"
:unread-count="unreadMessageCount" <ArticleContainer />
@start-conversation="startConversation"
/>
</div>
<div v-if="showArticles" class="w-full px-4 py-2">
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
<ArticleHero
v-if="
!articleUiFlags.isFetching &&
!articleUiFlags.isError &&
popularArticles.length
"
:articles="popularArticles"
@view="openArticleInArticleViewer"
@view-all="viewAllArticles"
/>
</div>
</div>
<div v-if="articleUiFlags.isFetching" class="w-full px-4 py-2">
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
<ArticleCardSkeletonLoader />
</div>
</div>
</div> </div>
</template> </template>
@@ -11,7 +11,13 @@ json.slug article.slug
if article.portal.present? if article.portal.present?
json.portal do json.portal do
json.partial! 'public/api/v1/models/hc/portal', formats: [:json], portal: article.portal json.custom_domain article.portal.custom_domain
json.header_text article.portal.header_text
json.homepage_link article.portal.homepage_link
json.name article.portal.name
json.page_title article.portal.page_title
json.slug article.portal.slug
json.logo article.portal.file_base_data if article.portal.logo.present?
end end
end end
@@ -31,12 +37,4 @@ if article.author.present?
end end
end end
json.associated_articles do
if article.associated_articles.any?
json.array! article.associated_articles.each do |associated_article|
json.partial! 'public/api/v1/models/hc/associated_article', formats: [:json], article: associated_article
end
end
end
json.link "hc/#{article.portal.slug}/articles/#{article.slug}" json.link "hc/#{article.portal.slug}/articles/#{article.slug}"
@@ -4,5 +4,5 @@ json.payload do
end end
json.meta do json.meta do
json.articles_count @articles.published.size json.articles_count @articles_count
end end
+1 -1
View File
@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
if Rails.env.development? if Rails.env.development? && ENV.fetch('ENABLE_MINI_PROFILER', 'false') == 'true'
require 'rack-mini-profiler' require 'rack-mini-profiler'
# initialization is skipped so trigger it # initialization is skipped so trigger it
+1
View File
@@ -79,6 +79,7 @@
"md5": "^2.3.0", "md5": "^2.3.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"opus-recorder": "^8.0.5", "opus-recorder": "^8.0.5",
"pinia": "^2.3.0",
"semver": "7.6.3", "semver": "7.6.3",
"snakecase-keys": "^8.0.1", "snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2", "timezone-phone-codes": "^0.0.2",
+37
View File
@@ -157,6 +157,9 @@ importers:
opus-recorder: opus-recorder:
specifier: ^8.0.5 specifier: ^8.0.5
version: 8.0.5 version: 8.0.5
pinia:
specifier: ^2.3.0
version: 2.3.1(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
semver: semver:
specifier: 7.6.3 specifier: 7.6.3
version: 7.6.3 version: 7.6.3
@@ -3894,6 +3897,15 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
pinia@2.3.1:
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pirates@4.0.6: pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -4885,6 +4897,17 @@ packages:
'@vue/composition-api': '@vue/composition-api':
optional: true optional: true
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-dompurify-html@5.1.0: vue-dompurify-html@5.1.0:
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==} resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
peerDependencies: peerDependencies:
@@ -9196,6 +9219,16 @@ snapshots:
pify@2.3.0: {} pify@2.3.0: {}
pinia@2.3.1(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.12(typescript@5.6.2)
vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2))
optionalDependencies:
typescript: 5.6.2
transitivePeerDependencies:
- '@vue/composition-api'
pirates@4.0.6: {} pirates@4.0.6: {}
pkcs7@1.0.4: pkcs7@1.0.4:
@@ -10333,6 +10366,10 @@ snapshots:
dependencies: dependencies:
vue: 3.5.12(typescript@5.6.2) vue: 3.5.12(typescript@5.6.2)
vue-demi@0.14.10(vue@3.5.12(typescript@5.6.2)):
dependencies:
vue: 3.5.12(typescript@5.6.2)
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)): vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
dependencies: dependencies:
dompurify: 3.2.4 dompurify: 3.2.4