Merge branch 'develop' into feat/ui-lib
This commit is contained in:
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
};
|
||||
@@ -65,6 +66,7 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
|
||||
+6
-1
@@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
overflow-y-auto
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
is-new-contact
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(response?.conversation_id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const iconByType = {
|
||||
text: 'i-lucide-align-justify',
|
||||
checkbox: 'i-lucide-circle-check-big',
|
||||
list: 'i-lucide-list',
|
||||
date: 'i-lucide-calendar',
|
||||
link: 'i-lucide-link',
|
||||
number: 'i-lucide-hash',
|
||||
};
|
||||
|
||||
const attributeIcon = computed(() => {
|
||||
const typeKey = props.attribute.type?.toLowerCase();
|
||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.label }}
|
||||
</h4>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon :icon="attributeIcon" class="size-4" />
|
||||
<span class="text-sm">{{ attribute.type }}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-n-weak" />
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<AttributeBadge
|
||||
v-for="badge in badges"
|
||||
:key="badge.type"
|
||||
:type="badge.type"
|
||||
/>
|
||||
<div
|
||||
v-if="badges.length > 0"
|
||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ attribute.attribute_description || attribute.description || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'resolution',
|
||||
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const attributeConfig = {
|
||||
'pre-chat': {
|
||||
colorClass: 'text-n-blue-11',
|
||||
icon: 'i-lucide-message-circle',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||
},
|
||||
resolution: {
|
||||
colorClass: 'text-n-teal-11',
|
||||
icon: 'i-lucide-circle-check-big',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||
},
|
||||
};
|
||||
const config = computed(
|
||||
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||
<span class="text-xs" :class="config.colorClass">{{
|
||||
t(config.labelKey)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ const props = defineProps({
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -106,6 +107,7 @@ watch(
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createNewContact,
|
||||
@@ -226,6 +228,8 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
@@ -163,6 +164,20 @@ const keyboardEvents = {
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const onPaste = e => {
|
||||
if (!props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const files = e.clipboardData?.files;
|
||||
if (!files?.length) return;
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
};
|
||||
|
||||
useEventListener(document, 'paste', onPaste);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+24
-6
@@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@@ -193,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
@@ -202,21 +210,27 @@ const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(state.message, props.messageSignature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -227,12 +241,16 @@ const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
inboxChannelType.value
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature);
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
@@ -356,10 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
+24
-97
@@ -1,122 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const state = ref({
|
||||
hasSlashCommand: false,
|
||||
showMentions: false,
|
||||
mentionSearchKey: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
if (props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const bodyWithoutSignature = newValue
|
||||
? removeSignature(newValue, props.messageSignature)
|
||||
: '';
|
||||
|
||||
// Check if message starts with slash
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Update slash command and mentions state
|
||||
state.value = {
|
||||
...state.value,
|
||||
hasSlashCommand: startsWithSlash,
|
||||
showMentions: startsWithSlash,
|
||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const hideMention = () => {
|
||||
state.value.showMentions = false;
|
||||
};
|
||||
|
||||
const replaceText = async message => {
|
||||
// Only append signature on replace if sendWithSignature is true
|
||||
const finalMessage = props.sendWithSignature
|
||||
? appendSignature(message, props.messageSignature, props.channelType)
|
||||
: message;
|
||||
|
||||
await nextTick();
|
||||
modelValue.value = finalMessage;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<template v-if="isEmailOrWebWidgetInbox">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TextArea
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
||||
:custom-text-area-class="
|
||||
hasErrors
|
||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
auto-height
|
||||
allow-signature
|
||||
:signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
>
|
||||
<CannedResponse
|
||||
v-if="state.showMentions && state.hasSlashCommand"
|
||||
v-on-clickaway="hideMention"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="state.mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
</TextArea>
|
||||
</template>
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
@@ -9,6 +10,8 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
@@ -35,7 +38,7 @@ const onClick = () => {
|
||||
v-for="(document, index) in documentsList.slice(0, 5)"
|
||||
:id="document.id"
|
||||
:key="`document-${index}`"
|
||||
:name="document.name"
|
||||
:name="replaceInstallationName(document.name)"
|
||||
:assistant="document.assistant"
|
||||
:external-link="document.external_link"
|
||||
:created-at="document.created_at"
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
@@ -26,6 +27,7 @@ const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -63,8 +65,8 @@ const onClearFilters = () => {
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
:id="response.id"
|
||||
:key="`response-${index}`"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:question="replaceInstallationName(response.question)"
|
||||
:answer="replaceInstallationName(response.answer)"
|
||||
:status="response.status"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-instagram');
|
||||
});
|
||||
|
||||
it('returns correct icon for TikTok channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Tiktok' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-tiktok');
|
||||
});
|
||||
|
||||
describe('TwilioSms channel', () => {
|
||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||
|
||||
@@ -28,6 +28,7 @@ import ImageBubble from './bubbles/Image.vue';
|
||||
import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -317,6 +318,7 @@ const componentToRender = computed(() => {
|
||||
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
||||
}
|
||||
// Attachment content is the name of the contact
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
@@ -60,7 +61,8 @@ const isSent = computed(() => {
|
||||
isAFacebookInbox.value ||
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value ||
|
||||
isAnInstagramChannel.value
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -78,7 +80,8 @@ const isDelivered = computed(() => {
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value
|
||||
isAFacebookInbox.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
@@ -100,7 +103,8 @@ const isRead = computed(() => {
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
|
||||
<div
|
||||
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
|
||||
>
|
||||
<iframe
|
||||
class="w-full h-full border-0 rounded-lg"
|
||||
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
|
||||
:src="attachment.dataUrl"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
@@ -49,6 +49,7 @@ export const ATTACHMENT_TYPES = {
|
||||
STORY_MENTION: 'story_mention',
|
||||
CONTACT: 'contact',
|
||||
IG_REEL: 'ig_reel',
|
||||
EMBED: 'embed',
|
||||
IG_POST: 'ig_post',
|
||||
IG_STORY: 'ig_story',
|
||||
};
|
||||
|
||||
@@ -9,11 +9,14 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
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 YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@@ -96,6 +99,15 @@ const closeMobileSidebar = () => {
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
@@ -623,14 +635,14 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right">
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
@click="toggle"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
@@ -651,6 +663,7 @@ const menuItems = computed(() => {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
/>
|
||||
<YearInReviewBanner />
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -22,6 +24,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
@@ -31,6 +34,29 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showYearInReviewModal = ref(false);
|
||||
|
||||
const bannerClosedKey = computed(() => {
|
||||
return `yir_closed_${accountId.value}_2025`;
|
||||
});
|
||||
|
||||
const isBannerClosed = computed(() => {
|
||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||
});
|
||||
|
||||
const showYearInReviewMenuItem = computed(() => {
|
||||
return isBannerClosed.value;
|
||||
});
|
||||
|
||||
const openYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = true;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const closeYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = false;
|
||||
};
|
||||
|
||||
const showChatSupport = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
@@ -42,6 +68,13 @@ const showChatSupport = computed(() => {
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
show: showYearInReviewMenuItem.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
||||
icon: 'i-lucide-gift',
|
||||
click: openYearInReviewModal,
|
||||
},
|
||||
{
|
||||
show: showChatSupport.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
@@ -157,4 +190,9 @@ const allowedMenuItems = computed(() => {
|
||||
</template>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
<YearInReviewModal
|
||||
:show="showYearInReviewModal"
|
||||
@close="closeYearInReviewModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
slideElement: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
slideBackground: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
year: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isGenerating = ref(false);
|
||||
const shareImageUrl = ref(null);
|
||||
|
||||
const generateImage = async () => {
|
||||
if (!props.slideElement) return;
|
||||
|
||||
isGenerating.value = true;
|
||||
try {
|
||||
let slideElement = props.slideElement;
|
||||
|
||||
if (slideElement && '$el' in slideElement) {
|
||||
slideElement = slideElement.$el;
|
||||
}
|
||||
|
||||
if (!slideElement) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No slide element found');
|
||||
return;
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
'bg-[#5BD58A]': '#5BD58A',
|
||||
'bg-[#60a5fa]': '#60a5fa',
|
||||
'bg-[#fb923c]': '#fb923c',
|
||||
'bg-[#f87171]': '#f87171',
|
||||
'bg-[#fbbf24]': '#fbbf24',
|
||||
};
|
||||
const bgColor = colorMap[props.slideBackground] || '#ffffff';
|
||||
|
||||
const dataUrl = await toPng(slideElement, {
|
||||
pixelRatio: 1.2,
|
||||
backgroundColor: bgColor,
|
||||
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
|
||||
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
|
||||
fontEmbedCSS: '',
|
||||
cacheBust: true,
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
const borderSize = 20;
|
||||
const bottomPadding = 50;
|
||||
|
||||
finalCanvas.width = img.width + borderSize * 2;
|
||||
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
|
||||
|
||||
const ctx = finalCanvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
||||
|
||||
ctx.drawImage(img, borderSize, borderSize);
|
||||
|
||||
ctx.fillStyle = '#1f2d3d';
|
||||
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
|
||||
borderSize,
|
||||
img.height + borderSize + 35
|
||||
);
|
||||
|
||||
const logo = new Image();
|
||||
logo.src = '/brand-assets/logo.svg';
|
||||
await new Promise(resolve => {
|
||||
logo.onload = resolve;
|
||||
});
|
||||
|
||||
const logoHeight = 30;
|
||||
const logoWidth = (logo.width / logo.height) * logoHeight;
|
||||
const logoX = finalCanvas.width - borderSize - logoWidth;
|
||||
const logoY = img.height + borderSize + 15;
|
||||
|
||||
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
|
||||
|
||||
shareImageUrl.value = finalCanvas.toDataURL('image/png');
|
||||
} catch (err) {
|
||||
// Handle errors silently for now
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to generate image:', err);
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!shareImageUrl.value) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = shareImageUrl.value;
|
||||
link.download = `chatwoot-year-in-review-${props.year}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const shareImage = async () => {
|
||||
if (!shareImageUrl.value) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(shareImageUrl.value);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
if (
|
||||
navigator.share &&
|
||||
navigator.canShare &&
|
||||
navigator.canShare({ files: [file] })
|
||||
) {
|
||||
await navigator.share({
|
||||
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
|
||||
year: props.year,
|
||||
}),
|
||||
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
|
||||
files: [file],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadImage();
|
||||
} catch (err) {
|
||||
// Fallback to download if sharing fails
|
||||
downloadImage();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
shareImageUrl.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (props.show && !shareImageUrl.value) {
|
||||
await generateImage();
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ handleOpen });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
|
||||
@click="close"
|
||||
>
|
||||
<div v-if="isGenerating" class="flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-white">
|
||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="shareImageUrl"
|
||||
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-medium text-white">
|
||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
|
||||
</h3>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<i class="i-lucide-x w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<img
|
||||
:src="shareImageUrl"
|
||||
alt="Year in Review"
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="downloadImage"
|
||||
>
|
||||
<i class="i-lucide-download w-5 h-5" />
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="shareImage"
|
||||
>
|
||||
<i class="i-lucide-share-2 w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import YearInReviewModal from './YearInReviewModal.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const yearInReviewBannerImage =
|
||||
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const getters = useStoreGetters();
|
||||
const showModal = ref(false);
|
||||
const modalRef = ref(null);
|
||||
|
||||
const currentYear = 2025;
|
||||
|
||||
const isACustomBrandedInstance =
|
||||
getters['globalConfig/isACustomBrandedInstance'];
|
||||
|
||||
const bannerClosedKey = computed(() => {
|
||||
const accountId = getters.getCurrentAccountId.value;
|
||||
return `yir_closed_${accountId}_${currentYear}`;
|
||||
});
|
||||
|
||||
const isBannerClosed = computed(() => {
|
||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||
});
|
||||
|
||||
const shouldShowBanner = computed(
|
||||
() => !isBannerClosed.value && !isACustomBrandedInstance.value
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const closeBanner = event => {
|
||||
event.stopPropagation();
|
||||
updateUISettings({ [bannerClosedKey.value]: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner" class="relative">
|
||||
<div
|
||||
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
|
||||
@click="openModal"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-3">
|
||||
<span
|
||||
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
|
||||
</span>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
|
||||
@click="closeBanner"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<img
|
||||
:src="yearInReviewBannerImage"
|
||||
alt="Year in Review"
|
||||
class="w-full h-auto rounded"
|
||||
/>
|
||||
<button
|
||||
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
|
||||
@click.stop="openModal"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import YearInReviewAPI from 'dashboard/api/yearInReview';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import IntroSlide from './slides/IntroSlide.vue';
|
||||
import ConversationsSlide from './slides/ConversationsSlide.vue';
|
||||
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
|
||||
import PersonalitySlide from './slides/PersonalitySlide.vue';
|
||||
import ThankYouSlide from './slides/ThankYouSlide.vue';
|
||||
import ShareModal from './ShareModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
const getters = useStoreGetters();
|
||||
const isOpen = ref(false);
|
||||
const currentSlide = ref(0);
|
||||
const yearData = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const slideRefs = ref([]);
|
||||
const showShareModal = ref(false);
|
||||
const shareModalRef = ref(null);
|
||||
const drumrollAudio = ref(null);
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return yearData.value?.total_conversations > 0;
|
||||
});
|
||||
|
||||
const totalSlides = computed(() => {
|
||||
if (!hasConversations.value) {
|
||||
return 3;
|
||||
}
|
||||
return 5;
|
||||
});
|
||||
|
||||
const slideIndexMap = computed(() => {
|
||||
if (!hasConversations.value) {
|
||||
return [0, 1, 4];
|
||||
}
|
||||
return [0, 1, 2, 3, 4];
|
||||
});
|
||||
|
||||
const currentVisualSlide = computed(() => {
|
||||
return slideIndexMap.value.indexOf(currentSlide.value);
|
||||
});
|
||||
|
||||
const slideBackgrounds = [
|
||||
'bg-[#5BD58A]',
|
||||
'bg-[#60a5fa]',
|
||||
'bg-[#fb923c]',
|
||||
'bg-[#f87171]',
|
||||
'bg-[#fbbf24]',
|
||||
];
|
||||
|
||||
const playDrumroll = () => {
|
||||
try {
|
||||
if (!drumrollAudio.value) {
|
||||
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
|
||||
drumrollAudio.value.volume = 0.5;
|
||||
}
|
||||
|
||||
drumrollAudio.value.currentTime = 0;
|
||||
drumrollAudio.value.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not play drumroll:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error playing drumroll:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchYearInReviewData = async () => {
|
||||
const year = 2025;
|
||||
const accountId = getters.getCurrentAccountId.value;
|
||||
const cacheKey = `year_in_review_${accountId}_${year}`;
|
||||
|
||||
const cachedData = uiSettings.value?.[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
yearData.value = cachedData;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await YearInReviewAPI.get(year);
|
||||
yearData.value = response.data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const nextSlide = () => {
|
||||
if (currentSlide.value < 4) {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
|
||||
if (!hasConversations.value && currentSlide.value === 1) {
|
||||
currentSlide.value = 4;
|
||||
} else {
|
||||
currentSlide.value += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previousSlide = () => {
|
||||
if (currentSlide.value > 0) {
|
||||
if (!hasConversations.value && currentSlide.value === 4) {
|
||||
currentSlide.value = 1;
|
||||
} else {
|
||||
currentSlide.value -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goToSlide = visualIndex => {
|
||||
currentSlide.value = slideIndexMap.value[visualIndex];
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
currentSlide.value = 0;
|
||||
isOpen.value = false;
|
||||
yearData.value = null;
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
|
||||
isOpen.value = true;
|
||||
fetchYearInReviewData();
|
||||
playDrumroll();
|
||||
};
|
||||
|
||||
const currentSlideBackground = computed(
|
||||
() => slideBackgrounds[currentSlide.value]
|
||||
);
|
||||
|
||||
const shareCurrentSlide = async () => {
|
||||
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
|
||||
showShareModal.value = true;
|
||||
nextTick(() => {
|
||||
if (shareModalRef.value) {
|
||||
shareModalRef.value.handleOpen();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
showShareModal.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: { action: close },
|
||||
ArrowLeft: { action: previousSlide },
|
||||
ArrowRight: { action: nextSlide },
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
newValue => {
|
||||
if (newValue) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
|
||||
>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-n-slate-11">
|
||||
{{ t('YEAR_IN_REVIEW.LOADING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold text-red-600">
|
||||
{{ t('YEAR_IN_REVIEW.ERROR') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
|
||||
<button
|
||||
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.CLOSE')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="yearData"
|
||||
class="relative w-full h-full"
|
||||
:class="currentSlideBackground"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<IntroSlide
|
||||
v-if="currentSlide === 0"
|
||||
:key="0"
|
||||
:ref="el => (slideRefs[0] = el)"
|
||||
:year="yearData.year"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<ConversationsSlide
|
||||
v-if="currentSlide === 1"
|
||||
:key="1"
|
||||
:ref="el => (slideRefs[1] = el)"
|
||||
:total-conversations="yearData.total_conversations"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<BusiestDaySlide
|
||||
v-if="
|
||||
currentSlide === 2 && hasConversations && yearData.busiest_day
|
||||
"
|
||||
:key="2"
|
||||
:ref="el => (slideRefs[2] = el)"
|
||||
:busiest-day="yearData.busiest_day"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<PersonalitySlide
|
||||
v-if="
|
||||
currentSlide === 3 &&
|
||||
hasConversations &&
|
||||
yearData.support_personality
|
||||
"
|
||||
:key="3"
|
||||
:ref="el => (slideRefs[3] = el)"
|
||||
:support-personality="yearData.support_personality"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<ThankYouSlide
|
||||
v-if="currentSlide === 4"
|
||||
:key="4"
|
||||
:ref="el => (slideRefs[4] = el)"
|
||||
:year="yearData.year"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
|
||||
>
|
||||
<button
|
||||
v-if="currentSlide > 0"
|
||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="previousSlide"
|
||||
>
|
||||
<i class="i-lucide-chevron-left w-5 h-5" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-else class="w-20" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="index in totalSlides"
|
||||
:key="index"
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
:class="
|
||||
currentVisualSlide === index - 1
|
||||
? 'bg-white w-8'
|
||||
: 'bg-white bg-opacity-50'
|
||||
"
|
||||
@click="goToSlide(index - 1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
|
||||
@click="nextSlide"
|
||||
>
|
||||
<span
|
||||
v-if="currentVisualSlide < totalSlides - 1"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="currentVisualSlide < totalSlides - 1"
|
||||
class="i-lucide-chevron-right w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="shareCurrentSlide"
|
||||
>
|
||||
<i class="i-lucide-share-2 w-5 h-5" />
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<i class="i-lucide-x w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
ref="shareModalRef"
|
||||
:show="showShareModal"
|
||||
:slide-element="slideRefs[currentSlide]"
|
||||
:slide-background="currentSlideBackground"
|
||||
:year="yearData?.year"
|
||||
@close="closeShareModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
busiestDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const coffeeImage =
|
||||
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const performanceHelperText = computed(() => {
|
||||
const count = props.busiestDay.count;
|
||||
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
|
||||
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
|
||||
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
|
||||
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
|
||||
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
|
||||
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
|
||||
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||
<div class="flex flex-col gap-4 w-full max-w-3xl">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
|
||||
<div class="text-white flex gap-2 flex-col">
|
||||
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
|
||||
{{ busiestDay.date }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="coffeeImage"
|
||||
alt="Coffee"
|
||||
class="w-auto h-32 md:h-56 lg:h-72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="flex items-center justify-center gap-3 md:gap-8">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{
|
||||
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
|
||||
count: busiestDay.count,
|
||||
})
|
||||
}}
|
||||
{{ performanceHelperText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
totalConversations: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const cloudImage =
|
||||
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const hasData = computed(() => {
|
||||
return props.totalConversations > 0;
|
||||
});
|
||||
|
||||
const formatNumber = num => {
|
||||
if (num >= 100000) {
|
||||
return '100k+';
|
||||
}
|
||||
return new Intl.NumberFormat().format(num);
|
||||
};
|
||||
|
||||
const performanceHelperText = computed(() => {
|
||||
if (!hasData.value) {
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
|
||||
}
|
||||
|
||||
const count = props.totalConversations;
|
||||
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
|
||||
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
|
||||
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
|
||||
if (count <= 2000)
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
|
||||
if (count <= 10000)
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-16"
|
||||
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div
|
||||
class="flex items-center justify-between gap-6 flex-1"
|
||||
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
|
||||
>
|
||||
<div class="text-white flex gap-3 flex-col">
|
||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
|
||||
{{ formatNumber(totalConversations) }}
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
|
||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="cloudImage"
|
||||
alt="Cloud"
|
||||
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ performanceHelperText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
year: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const candlesImagePath =
|
||||
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
|
||||
:style="{
|
||||
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
|
||||
}"
|
||||
>
|
||||
<div class="text-center max-w-3xl">
|
||||
<h1
|
||||
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ year }}
|
||||
</h1>
|
||||
<h2
|
||||
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.TITLE') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="candlesImagePath"
|
||||
alt="Candles"
|
||||
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
supportPersonality: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const clockImage =
|
||||
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const formatResponseTime = seconds => {
|
||||
if (seconds < 60) {
|
||||
return 'less than a minute';
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return hours === 1 ? '1 hour' : `${hours} hours`;
|
||||
}
|
||||
return 'more than a day';
|
||||
};
|
||||
|
||||
const personality = computed(() => {
|
||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < 2) {
|
||||
return 'Swift Helper';
|
||||
}
|
||||
if (minutes < 5) {
|
||||
return 'Quick Responder';
|
||||
}
|
||||
if (minutes < 15) {
|
||||
return 'Steady Support';
|
||||
}
|
||||
return 'Thoughtful Advisor';
|
||||
});
|
||||
|
||||
const personalityMessage = computed(() => {
|
||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||
const time = formatResponseTime(seconds);
|
||||
|
||||
const personalityKeyMap = {
|
||||
'Swift Helper': 'SWIFT_HELPER',
|
||||
'Quick Responder': 'QUICK_RESPONDER',
|
||||
'Steady Support': 'STEADY_SUPPORT',
|
||||
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
|
||||
};
|
||||
|
||||
const key = personalityKeyMap[personality.value];
|
||||
if (!key) return '';
|
||||
|
||||
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||
<div class="flex flex-col gap-9 max-w-3xl">
|
||||
<div class="mb-4 md:mb-6">
|
||||
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
|
||||
<div class="flex items-center justify-start flex-1 mt-9">
|
||||
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
|
||||
<div class="text-2xl md:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
|
||||
{{ personality }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ personalityMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
year: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const signatureImage =
|
||||
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||
>
|
||||
<div class="flex flex-col items-start max-w-4xl">
|
||||
<div
|
||||
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{
|
||||
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<img
|
||||
:src="signatureImage"
|
||||
alt="Chatwoot Team Signature"
|
||||
class="w-auto h-8 md:h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user