Merge branch 'develop' into chore/load-reply-message

This commit is contained in:
Sivin Varghese
2025-11-06 14:21:18 +05:30
committed by GitHub
588 changed files with 28840 additions and 5780 deletions
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</template>
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
};
const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
<div class="relative">
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
>
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
</div>
</template>
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
selectedContact: {
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Policy :permissions="['administrator']">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
@@ -10,7 +10,15 @@ import {
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const props = defineProps({
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n();
const store = useStore();
@@ -20,6 +28,9 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => {
try {
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
<div class="flex flex-col gap-4">
<div v-for="contact in contacts" :key="contact.id" class="relative">
<ContactsCard
:id="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
:selectable="shouldShowSelection(contact.id)"
:is-selected="isSelected(contact.id)"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div>
</template>
@@ -21,6 +21,10 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -100,6 +104,10 @@ watch(
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -14,6 +14,10 @@ defineProps({
type: Array,
default: () => [],
},
showBackdrop: {
type: Boolean,
default: true,
},
});
</script>
@@ -25,14 +29,24 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-6">
<div
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -40,6 +54,7 @@ defineProps({
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
@@ -92,7 +92,6 @@ const setSignature = () => {
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
// Added this watch to dynamically set signature on target inbox change.
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/>
<AttachmentPreviews
@@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
});
const { t } = useI18n();
@@ -90,6 +91,10 @@ const replaceText = async message => {
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
@@ -114,6 +114,7 @@ const handlePageChange = event => {
<slot name="action" />
</div>
</div>
<slot name="subHeader" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
@@ -64,20 +64,23 @@ const bulkCheckboxState = computed({
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
<span
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -59,6 +60,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<div
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
icon="i-lucide-circle-check-big"
sm
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
icon="i-lucide-pencil-line"
sm
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
icon="i-lucide-trash"
sm
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div>
</CardLayout>
</template>
@@ -6,16 +6,39 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
};
const onClearFilters = () => {
emit('clearFilters');
};
</script>
<template>
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
@@ -25,11 +48,16 @@ const onClick = () => {
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:title="
isPending
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:show-backdrop="isApproved"
>
<template #empty-state-item>
<template v-if="isApproved" #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
@@ -45,11 +73,21 @@ const onClick = () => {
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<div class="flex flex-col items-center gap-3">
<Button
v-if="isApproved"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -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>
@@ -0,0 +1,29 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -47,6 +47,7 @@ const isReel = computed(() => {
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@click.stop
@error="handleError"
/>
</div>
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -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';
@@ -32,11 +33,15 @@ const emit = defineEmits([
'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -532,20 +537,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">
@@ -570,7 +575,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"
@@ -580,11 +585,21 @@ 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 flex-col flex-shrink-0 relative gap-1 justify-between items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
<div
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>
</aside>
</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>