mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
feat: Changelog card components
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user