feat: Changelog card components (#12673)
# Pull Request Template ## Description This PR introduces a new changelog component that can be used in the sidebar. Fixes https://linear.app/chatwoot/issue/CW-5776/changelog-card-ui-component ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/42e77e82-388a-4fc9-9b37-f3d0ea1a9d7f ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
This commit is contained in:
16
app/javascript/dashboard/api/changelog.js
Normal file
16
app/javascript/dashboard/api/changelog.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
import { CHANGELOG_API_URL } from 'shared/constants/links';
|
||||||
|
|
||||||
|
class ChangelogApi extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('changelog', { apiVersion: 'v1' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
fetchFromHub() {
|
||||||
|
return axios.get(CHANGELOG_API_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ChangelogApi();
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
|
||||||
|
|
||||||
|
const sampleCards = [
|
||||||
|
{
|
||||||
|
id: 'chatwoot-captain',
|
||||||
|
title: 'Chatwoot Captain',
|
||||||
|
meta_title: 'Chatwoot Captain',
|
||||||
|
meta_description:
|
||||||
|
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||||
|
slug: 'chatwoot-captain',
|
||||||
|
feature_image:
|
||||||
|
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'smart-routing',
|
||||||
|
title: 'Smart Routing Forms',
|
||||||
|
meta_title: 'Smart Routing Forms',
|
||||||
|
meta_description:
|
||||||
|
'Screen bookers with intelligent forms and route them to the right team member.',
|
||||||
|
slug: 'smart-routing',
|
||||||
|
feature_image: 'https://www.chatwoot.com/images/dashboard-dark.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'instant-meetings',
|
||||||
|
title: 'Instant Meetings',
|
||||||
|
meta_title: 'Instant Meetings',
|
||||||
|
meta_description: 'Start instant meetings directly from shared links.',
|
||||||
|
slug: 'instant-meetings',
|
||||||
|
feature_image:
|
||||||
|
'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
title: 'Advanced Analytics',
|
||||||
|
meta_title: 'Advanced Analytics',
|
||||||
|
meta_description:
|
||||||
|
'Track meeting performance, conversion, and response rates in one place.',
|
||||||
|
slug: 'analytics',
|
||||||
|
feature_image:
|
||||||
|
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'team-collaboration',
|
||||||
|
title: 'Team Collaboration',
|
||||||
|
meta_title: 'Team Collaboration',
|
||||||
|
meta_description:
|
||||||
|
'Coordinate with your team seamlessly using shared availability.',
|
||||||
|
slug: 'team-collaboration',
|
||||||
|
feature_image:
|
||||||
|
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleCards = ref([...sampleCards]);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const dismissingCards = ref([]);
|
||||||
|
|
||||||
|
const handleReadMore = slug => {
|
||||||
|
console.log(`Read more: ${slug}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = slug => {
|
||||||
|
dismissingCards.value.push(slug);
|
||||||
|
setTimeout(() => {
|
||||||
|
const idx = visibleCards.value.findIndex(c => c.slug === slug);
|
||||||
|
if (idx !== -1) visibleCards.value.splice(idx, 1);
|
||||||
|
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||||
|
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImgClick = data => {
|
||||||
|
currentIndex.value = data.index;
|
||||||
|
console.log(`Card clicked: ${visibleCards.value[data.index].title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDemo = () => {
|
||||||
|
visibleCards.value = [...sampleCards];
|
||||||
|
currentIndex.value = 0;
|
||||||
|
dismissingCards.value = [];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Components/ChangelogCard/GroupedStackedChangelogCard"
|
||||||
|
:layout="{ type: 'grid', width: '320px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Interactive Demo">
|
||||||
|
<div class="p-4 bg-n-solid-2 rounded-md mx-auto w-64 h-[400px]">
|
||||||
|
<GroupedStackedChangelogCard
|
||||||
|
:posts="visibleCards"
|
||||||
|
:current-index="currentIndex"
|
||||||
|
:is-active="currentIndex === 0"
|
||||||
|
:dismissing-slugs="dismissingCards"
|
||||||
|
class="min-h-[270px]"
|
||||||
|
@read-more="handleReadMore"
|
||||||
|
@dismiss="handleDismiss"
|
||||||
|
@img-click="handleImgClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-3 px-3 py-1 text-xs font-medium bg-n-brand text-white rounded hover:bg-n-brand/80 transition"
|
||||||
|
@click="resetDemo"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||||
|
{{ 'Reset Cards' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
posts: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
dismissingSlugs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||||
|
|
||||||
|
const stackedPosts = computed(() => props.posts?.slice(0, 5));
|
||||||
|
|
||||||
|
const isPostDismissing = post => props.dismissingSlugs.includes(post.slug);
|
||||||
|
|
||||||
|
const handleReadMore = post => emit('readMore', post.slug);
|
||||||
|
const handleDismiss = post => emit('dismiss', post.slug);
|
||||||
|
const handlePostClick = (post, index) => {
|
||||||
|
if (index === props.currentIndex && !isPostDismissing(post)) {
|
||||||
|
emit('imgClick', { slug: post.slug, index });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardClasses = index => {
|
||||||
|
const pos =
|
||||||
|
(index - props.currentIndex + stackedPosts.value.length) %
|
||||||
|
stackedPosts.value.length;
|
||||||
|
const base =
|
||||||
|
'relative transition-all duration-500 ease-out col-start-1 row-start-1';
|
||||||
|
|
||||||
|
const layers = [
|
||||||
|
'z-50 scale-100 translate-y-0 opacity-100',
|
||||||
|
'z-40 scale-[0.95] -translate-y-3 opacity-90',
|
||||||
|
'z-30 scale-[0.9] -translate-y-6 opacity-70',
|
||||||
|
'z-20 scale-[0.85] -translate-y-9 opacity-50',
|
||||||
|
'z-10 scale-[0.8] -translate-y-12 opacity-30',
|
||||||
|
];
|
||||||
|
|
||||||
|
return pos < layers.length
|
||||||
|
? `${base} ${layers[pos]}`
|
||||||
|
: `${base} opacity-0 scale-75 -translate-y-16`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="relative grid grid-cols-1 pt-8 pb-1 px-2">
|
||||||
|
<div
|
||||||
|
v-for="(post, index) in stackedPosts"
|
||||||
|
:key="post.slug || index"
|
||||||
|
:class="getCardClasses(index)"
|
||||||
|
>
|
||||||
|
<StackedChangelogCard
|
||||||
|
:card="post"
|
||||||
|
:is-active="index === currentIndex"
|
||||||
|
:is-dismissing="isPostDismissing(post)"
|
||||||
|
@read-more="handleReadMore(post)"
|
||||||
|
@dismiss="handleDismiss(post)"
|
||||||
|
@img-click="handlePostClick(post, index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||||
|
|
||||||
|
const imageCards = {
|
||||||
|
id: 'chatwoot-captain',
|
||||||
|
title: 'Chatwoot Captain',
|
||||||
|
meta_title: 'Chatwoot Captain',
|
||||||
|
meta_description:
|
||||||
|
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||||
|
slug: 'chatwoot-captain',
|
||||||
|
feature_image:
|
||||||
|
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReadMore = () => {
|
||||||
|
console.log(`Read more: ${imageCards.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
console.log(`Dismissed: ${imageCards.title}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImgClick = () => {
|
||||||
|
console.log(`Card clicked: ${imageCards.title}`);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Components/ChangelogCard/StackedChangelogCard"
|
||||||
|
:layout="{ type: 'grid', width: '260px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Single Card - With Image">
|
||||||
|
<div class="p-3 bg-n-solid-2 w-56">
|
||||||
|
<StackedChangelogCard
|
||||||
|
:card="imageCards"
|
||||||
|
is-active
|
||||||
|
:is-dismissing="false"
|
||||||
|
@read-more="handleReadMore(imageCards)"
|
||||||
|
@dismiss="handleDismiss(imageCards)"
|
||||||
|
@img-click="handleImgClick(imageCards)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
card: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isDismissing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||||
|
|
||||||
|
const handleReadMore = () => {
|
||||||
|
emit('readMore');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
emit('dismiss');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImgClick = () => {
|
||||||
|
emit('imgClick');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-testid="changelog-card"
|
||||||
|
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
|
||||||
|
:class="{
|
||||||
|
'animate-fade-out pointer-events-none': isDismissing,
|
||||||
|
'hover:shadow': isActive,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h5
|
||||||
|
:title="card.meta_title"
|
||||||
|
class="mb-1 text-sm font-semibold line-clamp-1 text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ card.meta_title }}
|
||||||
|
</h5>
|
||||||
|
<p
|
||||||
|
:title="card.meta_description"
|
||||||
|
class="mb-0 text-xs leading-relaxed text-n-slate-11 line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ card.meta_description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="card.feature_image"
|
||||||
|
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="card.feature_image"
|
||||||
|
:alt="`${card.title} preview image`"
|
||||||
|
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||||
|
loading="lazy"
|
||||||
|
@click.stop="handleImgClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="card.feature_image"
|
||||||
|
:alt="`${card.title} preview image`"
|
||||||
|
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||||
|
loading="lazy"
|
||||||
|
@click.stop="handleImgClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mt-1">
|
||||||
|
<Button
|
||||||
|
label="Read more"
|
||||||
|
color="slate"
|
||||||
|
link
|
||||||
|
sm
|
||||||
|
class="text-xs font-normal hover:!no-underline"
|
||||||
|
@click.stop="handleReadMore"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Dismiss"
|
||||||
|
color="slate"
|
||||||
|
link
|
||||||
|
sm
|
||||||
|
class="text-xs font-normal hover:!no-underline"
|
||||||
|
@click.stop="handleDismiss"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-out {
|
||||||
|
animation: fade-out 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
@@ -32,11 +33,15 @@ const emit = defineEmits([
|
|||||||
'closeMobileSidebar',
|
'closeMobileSidebar',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { accountScopedRoute } = useAccount();
|
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isACustomBrandedInstance = useMapGetter(
|
||||||
|
'globalConfig/isACustomBrandedInstance'
|
||||||
|
);
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
emit('openKeyShortcutModal');
|
emit('openKeyShortcutModal');
|
||||||
@@ -532,20 +537,20 @@ const menuItems = computed(() => {
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex gap-2 items-center px-2 min-w-0">
|
||||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||||
<Logo class="size-4" />
|
<Logo class="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||||
<SidebarAccountSwitcher
|
<SidebarAccountSwitcher
|
||||||
class="flex-grow min-w-0 -mx-1"
|
class="flex-grow -mx-1 min-w-0"
|
||||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 px-2">
|
<div class="flex gap-2 px-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'search' }"
|
:to="{ name: 'search' }"
|
||||||
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||||
>
|
>
|
||||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||||
<span class="flex-grow text-left">
|
<span class="flex-grow text-left">
|
||||||
@@ -570,7 +575,7 @@ const menuItems = computed(() => {
|
|||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
|
||||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
@@ -580,11 +585,21 @@ const menuItems = computed(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<section
|
<section
|
||||||
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
|
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
||||||
>
|
>
|
||||||
<SidebarProfileMenu
|
<div
|
||||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||||
/>
|
/>
|
||||||
|
<SidebarChangelogCard
|
||||||
|
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||||
|
>
|
||||||
|
<SidebarProfileMenu
|
||||||
|
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import changelogAPI from 'dashboard/api/changelog';
|
||||||
|
|
||||||
|
const MAX_DISMISSED_SLUGS = 5;
|
||||||
|
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const posts = ref([]);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const dismissingCards = ref([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
// Get current dismissed slugs from ui_settings
|
||||||
|
const dismissedSlugs = computed(() => {
|
||||||
|
return uiSettings.value.changelog_dismissed_slugs || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get un dismissed posts - these are the changelog posts that should be shown
|
||||||
|
const unDismissedPosts = computed(() => {
|
||||||
|
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch changelog posts from API
|
||||||
|
const fetchChangelog = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await changelogAPI.fetchFromHub();
|
||||||
|
posts.value = response.data.posts || [];
|
||||||
|
|
||||||
|
// Clean up dismissed slugs - remove any that are no longer in the current feed
|
||||||
|
const currentSlugs = posts.value.map(post => post.slug);
|
||||||
|
const cleanedDismissedSlugs = dismissedSlugs.value.filter(slug =>
|
||||||
|
currentSlugs.includes(slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update ui_settings if cleanup occurred
|
||||||
|
if (cleanedDismissedSlugs.length !== dismissedSlugs.value.length) {
|
||||||
|
updateUISettings({
|
||||||
|
changelog_dismissed_slugs: cleanedDismissedSlugs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch (err) {
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dismiss a changelog post
|
||||||
|
const dismissPost = slug => {
|
||||||
|
const currentDismissed = [...dismissedSlugs.value];
|
||||||
|
|
||||||
|
// Add new slug if not already present
|
||||||
|
if (!currentDismissed.includes(slug)) {
|
||||||
|
currentDismissed.push(slug);
|
||||||
|
|
||||||
|
// Keep only the most recent MAX_DISMISSED_SLUGS entries
|
||||||
|
if (currentDismissed.length > MAX_DISMISSED_SLUGS) {
|
||||||
|
currentDismissed.shift(); // Remove oldest entry
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUISettings({
|
||||||
|
changelog_dismissed_slugs: currentDismissed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = slug => {
|
||||||
|
dismissingCards.value.push(slug);
|
||||||
|
setTimeout(() => {
|
||||||
|
dismissPost(slug);
|
||||||
|
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||||
|
if (currentIndex.value >= unDismissedPosts.value.length)
|
||||||
|
currentIndex.value = 0;
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReadMore = () => {
|
||||||
|
const currentPost = unDismissedPosts.value[currentIndex.value];
|
||||||
|
if (currentPost?.slug) {
|
||||||
|
window.open(`https://www.chatwoot.com/blog/${currentPost.slug}`, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImgClick = ({ index }) => {
|
||||||
|
currentIndex.value = index;
|
||||||
|
handleReadMore();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchChangelog();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GroupedStackedChangelogCard
|
||||||
|
v-if="unDismissedPosts.length > 0"
|
||||||
|
:posts="unDismissedPosts"
|
||||||
|
:current-index="currentIndex"
|
||||||
|
:dismissing-slugs="dismissingCards"
|
||||||
|
class="min-h-[240px] z-10"
|
||||||
|
@read-more="handleReadMore"
|
||||||
|
@dismiss="handleDismiss"
|
||||||
|
@img-click="handleImgClick"
|
||||||
|
/>
|
||||||
|
<template v-else />
|
||||||
|
</template>
|
||||||
@@ -6,3 +6,5 @@ export const REPLY_POLICY = {
|
|||||||
WHATSAPP_CLOUD:
|
WHATSAPP_CLOUD:
|
||||||
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
|
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
|
||||||
|
|||||||
Reference in New Issue
Block a user