Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
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