feat: Changelog card components

This commit is contained in:
iamsivin
2025-10-15 15:18:03 +05:30
parent 368d7c4608
commit 7ba6271a2c
6 changed files with 534 additions and 4 deletions

View File

@@ -0,0 +1,129 @@
<script setup>
import { ref } from 'vue';
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
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 visibleCards = ref([...sampleCards]);
const currentIndex = ref(0);
const dismissingCards = ref([]);
const handlePrimaryAction = slug => {
const card = visibleCards.value.find(c => c.slug === slug);
console.log(`Primary action: ${card?.title}`);
};
const handleSecondaryAction = 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 handleCardClick = index => {
currentIndex.value = index;
console.log(`Card clicked: ${visibleCards.value[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
:cards="visibleCards"
:current-index="currentIndex"
:dismissing-cards="dismissingCards"
class="min-h-[200px]"
@primary-action="handlePrimaryAction"
@secondary-action="handleSecondaryAction"
@card-click="handleCardClick"
/>
<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>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import StackedChangelogCard from './StackedChangelogCard.vue';
const props = defineProps({
cards: {
type: Array,
required: true,
},
currentIndex: {
type: Number,
default: 0,
},
dismissingCards: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['primaryAction', 'secondaryAction', 'cardClick']);
const stackedCards = computed(() => props.cards?.slice(0, 5));
const isCardDismissing = card => props.dismissingCards.includes(card.slug);
const handlePrimaryAction = card => emit('primaryAction', card.slug);
const handleSecondaryAction = card => emit('secondaryAction', card.slug);
const handleCardClick = (card, index) => {
if (index !== props.currentIndex && !isCardDismissing(card)) {
emit('cardClick', { slug: card.slug, index });
}
};
const getCardClasses = index => {
const pos =
(index - props.currentIndex + stackedCards.value.length) %
stackedCards.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="hidden pb-2 pt-8 lg:grid relative grid-cols-1">
<div
v-for="(card, index) in stackedCards"
:key="card.slug || index"
:class="getCardClasses(index)"
>
<StackedChangelogCard
:card="card"
:is-active="index === currentIndex"
:show-actions="index === currentIndex"
:show-media="index === currentIndex"
:is-dismissing="isCardDismissing(card)"
@primary-action="handlePrimaryAction(card)"
@secondary-action="handleSecondaryAction(card)"
@card-click="handleCardClick(card, index)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import StackedChangelogCard from './StackedChangelogCard.vue';
const imageCards = {
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',
},
};
const videoCards = {
id: 'chatwoot-captain-preview',
title: 'Chatwoot Captain Preview',
slug: 'chatwoot-captain-preview',
description:
'Watch how our latest feature can transform your workflow with powerful automation tools.',
media: {
type: 'video',
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4',
poster:
'https://i.ytimg.com/an_webp/-c_xDMtoOG0/mqdefault_6s.webp?du=3000&sqp=CKrwvMcG&rs=AOn4CLCwHpsblr0TRE9yI7UuEY4aLyr8sw',
alt: 'Chatwoot Captain demo video',
},
};
const handlePrimaryAction = data => {
console.log('Primary action clicked:', data);
console.log(`Primary action: ${data.card.title}`);
};
const handleSecondaryAction = data => {
console.log('Secondary action clicked:', data);
console.log(`Dismissed: ${data.card.title}`);
};
const handleCardClick = data => {
console.log('Card clicked:', data);
console.log(`Card clicked: ${data.card.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
show-actions
show-media
@primary-action="handlePrimaryAction"
@secondary-action="handleSecondaryAction"
@card-click="handleCardClick"
/>
</div>
</Variant>
<Variant title="Single Card - With Video">
<div class="p-3 bg-n-solid-2 w-56">
<StackedChangelogCard
:card="videoCards"
is-active
show-actions
show-media
@primary-action="handlePrimaryAction"
@secondary-action="handleSecondaryAction"
@card-click="handleCardClick"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,130 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
card: {
type: Object,
required: true,
},
primaryAction: {
type: Object,
default: () => ({ label: 'Try now', color: 'slate' }),
},
secondaryAction: {
type: Object,
default: () => ({ label: 'Dismiss', color: 'slate' }),
},
isActive: {
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: true,
},
isDismissing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['primaryAction', 'secondaryAction', 'cardClick']);
const handlePrimaryAction = () => {
emit('primaryAction', { card: props.card });
};
const handleSecondaryAction = () => {
emit('secondaryAction', { card: props.card });
};
const handleCardClick = () => {
emit('cardClick', { card: props.card });
};
</script>
<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="{ '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"
>
{{ card.title }}
</h5>
<p
:title="card.description"
class="text-xs text-n-slate-11 line-clamp-2 mb-0 leading-relaxed"
>
{{ card.description }}
</p>
</div>
<a
v-if="card.media && isActive"
:href="card.media.src"
target="_blank"
rel="noopener noreferrer"
class="my-3 border border-n-weak/40 rounded-md block overflow-hidden"
@click.stop
>
<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"
loading="lazy"
/>
</a>
<div v-if="showActions" class="mt-2 flex items-center justify-between">
<Button
:label="primaryAction.label"
:color="primaryAction.color"
link
sm
class="text-xs font-normal hover:!no-underline"
@click.stop="handlePrimaryAction"
/>
<Button
:label="secondaryAction.label"
:color="secondaryAction.color"
link
sm
class="text-xs font-normal hover:!no-underline"
@click.stop="handleSecondaryAction"
/>
</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>

View File

@@ -13,6 +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 ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -580,11 +581,16 @@ const menuItems = computed(() => {
</ul>
</nav>
<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-shrink-0 flex flex-col justify-between gap-2 items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<!-- <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)]"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
</div>
</section>
</aside>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { ref } from 'vue';
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
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 visibleCards = ref([...sampleCards]);
const currentIndex = ref(0);
const dismissingCards = ref([]);
const handlePrimaryAction = () => {
// TODO
};
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);
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
}, 200);
};
const handleCardClick = () => {
// TODO
};
</script>
<template>
<div v-if="visibleCards.length > 0" class="px-2 pt-1">
<GroupedStackedChangelogCard
:cards="visibleCards"
:current-index="currentIndex"
:dismissing-cards="dismissingCards"
class="min-h-[240px]"
@primary-action="handlePrimaryAction"
@secondary-action="handleSecondaryAction"
@card-click="handleCardClick"
/>
</div>
<template v-else />
</template>