mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
chore: add api
This commit is contained in:
67
app/javascript/dashboard/api/changelog.js
Normal file
67
app/javascript/dashboard/api/changelog.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class ChangelogApi extends ApiClient {
|
||||
constructor() {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
// Return hardcoded data for now - will be replaced with API URL later
|
||||
const hardcodedData = {
|
||||
posts: [
|
||||
{
|
||||
url: 'https://www-internal-blog.chatwoot.com/whatsapp-account-health-and-allowed-domains/',
|
||||
slug: 'whatsapp-account-health-and-allowed-domains',
|
||||
title: 'WhatsApp Account Health and Allowed Domains',
|
||||
excerpt:
|
||||
"WhatsApp Account Health\n\nWe've added a new Account Health tab under WhatsApp Inbox settings. It shows your number's current status, messaging limits, display name, and quality rating all in one place.\n\nThis helps teams quickly verify if their number is active, healthy, and ready to send messages without having to switch to Meta Business Manager. For deeper checks, there's a direct link to open your WhatsApp Manager dashboard.\n\n\nWeb Widget: Allowed Domains\n\nYou can now restrict where your web wid",
|
||||
published_at: '2025-10-10T12:29:54.000+00:00',
|
||||
feature_image: null,
|
||||
},
|
||||
{
|
||||
url: 'https://www-internal-blog.chatwoot.com/mfa-and-pdf-support/',
|
||||
slug: 'mfa-and-pdf-support',
|
||||
title: 'Multi-Factor Authentication and Captain PDF Uploads',
|
||||
excerpt:
|
||||
"This release focuses on practical improvements that make everyday work smoother: stronger security, and better ways to manage knowledge in Captain. Alongside these, we've made a set of smaller updates and fixes that address feedback from teams using Chatwoot day to day.\n\n\nCaptain Now Supports PDF Documents\n\n\nYou can now upload PDFs as knowledge sources in Captain. This allows you to bring existing documents like product manuals, training guides, or policy documents into Captain without convertin",
|
||||
published_at: '2025-09-19T12:13:10.000+00:00',
|
||||
feature_image: null,
|
||||
},
|
||||
{
|
||||
url: 'https://www-internal-blog.chatwoot.com/twilio-content-templates/',
|
||||
slug: 'twilio-content-templates',
|
||||
title: 'Twilio Content Templates',
|
||||
excerpt:
|
||||
"This release brings one of the most requested features, along with a handful of quality-of-life improvements and a set of smaller fixes.\n\nIf you're using Twilio's WhatsApp Business API, you can send pre-approved WhatsApp templates directly from Chatwoot. These templates are required to start new WhatsApp conversations and are also useful for sending structured messages, like appointment reminders or verification codes, that comply with WhatsApp's rules.\n\nPlease read more details about here.\n\n\nOt",
|
||||
published_at: '2025-08-29T11:14:16.000+00:00',
|
||||
feature_image: null,
|
||||
},
|
||||
{
|
||||
url: 'https://www-internal-blog.chatwoot.com/enhanced-whatsapp-templates-coexistence-and-small-fixes/',
|
||||
slug: 'enhanced-whatsapp-templates-coexistence-and-small-fixes',
|
||||
title: 'Enhanced WhatsApp Templates and Coexistence',
|
||||
excerpt:
|
||||
'This release brings better WhatsApp support and a set of small fixes across the app. Nothing big, just steady improvements to make daily use easier.\n\n\nEnhanced WhatsApp Template Support with Media Headers\n\nYou can now use media headers, buttons, and structured fields in WhatsApp templates. This makes templates look and feel closer to the final message that customers receive.\n\nFor example, a promotion can now include a product photo, a call‑to‑action button, and placeholders for names or prices..',
|
||||
published_at: '2025-08-18T10:32:47.000+00:00',
|
||||
feature_image: null,
|
||||
},
|
||||
{
|
||||
url: 'https://www-internal-blog.chatwoot.com/easier-custom-domains-and-mobile-ui-improvements/',
|
||||
slug: 'easier-custom-domains-and-mobile-ui-improvements',
|
||||
title: 'Easier Custom Domains and Mobile UI Improvements',
|
||||
excerpt:
|
||||
"This update is focused on simplifying a few core workflows, mainly around custom domains and mobile experience. It's a smaller release, but a meaningful one if you're working with our Help Center or managing inboxes on the go.\n\n\n🌐 Custom Domain Setup Made Easier\n\nWe've improved the flow for setting up custom domains on your Help Center. You can now verify DNS and activate SSL certificates directly from the Chatwoot UI—no need to reach out to support or wait for manual intervention.\n\nOnce you up",
|
||||
published_at: '2025-08-04T14:03:29.000+00:00',
|
||||
feature_image: null,
|
||||
},
|
||||
],
|
||||
last_synced_at: '2025-10-15T08:19:01.226Z',
|
||||
synced_posts_count: 5,
|
||||
};
|
||||
|
||||
return Promise.resolve({ data: hardcodedData });
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChangelogApi();
|
||||
@@ -8,7 +8,7 @@ const props = defineProps({
|
||||
},
|
||||
primaryAction: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'Try now', color: 'slate' }),
|
||||
default: () => ({ label: 'Read more', color: 'slate' }),
|
||||
},
|
||||
secondaryAction: {
|
||||
type: Object,
|
||||
@@ -24,14 +24,14 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['primaryAction', 'secondaryAction', 'cardClick']);
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'cardClick']);
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
emit('primaryAction', { card: props.card });
|
||||
const handleReadMore = () => {
|
||||
emit('readMore', { card: props.card });
|
||||
};
|
||||
|
||||
const handleSecondaryAction = () => {
|
||||
emit('secondaryAction', { card: props.card });
|
||||
const handleDismiss = () => {
|
||||
emit('dismiss', { card: props.card });
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
@@ -42,59 +42,56 @@ const handleCardClick = () => {
|
||||
<template>
|
||||
<div
|
||||
data-testid="changelog-card"
|
||||
class="flex flex-col justify-between w-full p-3 border-n-weak hover:shadow-md rounded-lg border bg-n-background text-n-slate-12 shadow-sm transition-all duration-200 cursor-pointer"
|
||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 cursor-pointer border-n-weak hover:shadow-md bg-n-background text-n-slate-12"
|
||||
:class="{ 'animate-fade-out pointer-events-none': isDismissing }"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<div>
|
||||
<h5
|
||||
:title="card.title"
|
||||
class="line-clamp-1 font-semibold text-sm text-n-slate-12 mb-1"
|
||||
class="mb-1 text-sm font-semibold line-clamp-1 text-n-slate-12"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h5>
|
||||
<p
|
||||
:title="card.description"
|
||||
class="text-xs text-n-slate-11 line-clamp-2 mb-0 leading-relaxed"
|
||||
:title="card.excerpt"
|
||||
class="mb-0 text-xs leading-relaxed text-n-slate-11 line-clamp-2"
|
||||
>
|
||||
{{ card.description }}
|
||||
{{ card.excerpt }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="card.media"
|
||||
:href="card.media.src"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="my-3 border border-n-weak/40 rounded-md block overflow-hidden"
|
||||
@click.stop
|
||||
<div
|
||||
v-if="card.feature_image"
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<video
|
||||
v-if="card.media.type === 'video'"
|
||||
:src="card.media.src"
|
||||
:alt="card.media.alt || card.title"
|
||||
class="w-full h-24 object-cover rounded-md"
|
||||
:poster="card.media.poster"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<img
|
||||
v-else-if="card.media.type === 'image'"
|
||||
:src="card.media.src"
|
||||
:alt="card.media.alt || card.title"
|
||||
class="w-full h-24 object-cover rounded-md"
|
||||
:src="card.feature_image"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=600"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions" class="mt-2 flex items-center justify-between">
|
||||
<div v-if="showActions" class="flex justify-between items-center mt-2">
|
||||
<Button
|
||||
:label="primaryAction.label"
|
||||
:color="primaryAction.color"
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handlePrimaryAction"
|
||||
@click.stop="handleReadMore"
|
||||
/>
|
||||
<Button
|
||||
:label="secondaryAction.label"
|
||||
@@ -102,7 +99,7 @@ const handleCardClick = () => {
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handleSecondaryAction"
|
||||
@click.stop="handleDismiss"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
// import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@@ -533,20 +533,20 @@ const menuItems = computed(() => {
|
||||
]"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||
<div class="flex gap-2 items-center px-2 min-w-0">
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow min-w-0 -mx-1"
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 px-2">
|
||||
<RouterLink
|
||||
: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-grow text-left">
|
||||
@@ -571,7 +571,7 @@ const menuItems = computed(() => {
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</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">
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
@@ -581,9 +581,9 @@ const menuItems = computed(() => {
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="flex-shrink-0 flex flex-col justify-between gap-2 items-center"
|
||||
class="flex flex-col flex-shrink-0 gap-2 justify-between items-center"
|
||||
>
|
||||
<!-- <SidebarChangelogCard /> -->
|
||||
<SidebarChangelogCard />
|
||||
<div
|
||||
class="p-1 flex-shrink-0 flex w-full justify-between gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
>
|
||||
|
||||
@@ -1,95 +1,103 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
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 sampleCards = [
|
||||
{
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
slug: 'chatwoot-captain',
|
||||
description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
alt: 'Chatwoot Captain demo image',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'smart-routing',
|
||||
title: 'Smart Routing Forms',
|
||||
slug: 'smart-routing',
|
||||
description:
|
||||
'Screen bookers with intelligent forms and route them to the right team member.',
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
|
||||
poster:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerFun.jpg',
|
||||
alt: 'Routing forms demo video',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'instant-meetings',
|
||||
title: 'Instant Meetings',
|
||||
slug: 'instant-meetings',
|
||||
description: 'Start instant meetings directly from shared links.',
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
|
||||
alt: 'Instant meetings UI preview',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Advanced Analytics',
|
||||
slug: 'analytics',
|
||||
description:
|
||||
'Track meeting performance, conversion, and response rates in one place.',
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
|
||||
poster:
|
||||
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg',
|
||||
alt: 'Analytics dashboard video preview',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'team-collaboration',
|
||||
title: 'Team Collaboration',
|
||||
slug: 'team-collaboration',
|
||||
description:
|
||||
'Coordinate with your team seamlessly using shared availability.',
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=600',
|
||||
alt: 'Team collaboration meeting view',
|
||||
},
|
||||
},
|
||||
];
|
||||
const MAX_DISMISSED_SLUGS = 5;
|
||||
|
||||
const visibleCards = ref([...sampleCards]);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const posts = ref([]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Get current dismissed slugs from ui_settings
|
||||
const dismissedSlugs = computed(() => {
|
||||
return uiSettings.value.changelog_dismissed_slugs || [];
|
||||
});
|
||||
|
||||
// Get undismissed posts - pass them directly without transformation
|
||||
const visibleCards = computed(() => {
|
||||
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
|
||||
});
|
||||
|
||||
// Fetch changelog posts from API
|
||||
const fetchChangelog = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch changelog:', 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 handlePrimaryAction = () => {
|
||||
// TODO
|
||||
const currentCard = visibleCards.value[currentIndex.value];
|
||||
if (currentCard?.url) {
|
||||
window.open(currentCard.url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecondaryAction = slug => {
|
||||
// TODO Update this function
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
const idx = visibleCards.value.findIndex(c => c.slug === slug);
|
||||
if (idx !== -1) visibleCards.value.splice(idx, 1);
|
||||
dismissPost(slug);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
// TODO
|
||||
const currentCard = visibleCards.value[currentIndex.value];
|
||||
if (currentCard?.url) {
|
||||
window.open(currentCard.url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChangelog();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user