feat(ce): Add Year in review feature (#13078)
<img width="1502" height="813" alt="Screenshot 2025-12-15 at 5 01 57 PM" src="https://github.com/user-attachments/assets/ea721f00-403c-4adc-8410-5c0fa4ee4122" />
74
app/builders/year_in_review_builder.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
class YearInReviewBuilder
|
||||||
|
attr_reader :account, :user_id, :year
|
||||||
|
|
||||||
|
def initialize(account:, user_id:, year:)
|
||||||
|
@account = account
|
||||||
|
@user_id = user_id
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
{
|
||||||
|
year: year,
|
||||||
|
total_conversations: total_conversations_count,
|
||||||
|
busiest_day: busiest_day_data,
|
||||||
|
support_personality: support_personality_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def year_range
|
||||||
|
@year_range ||= begin
|
||||||
|
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
||||||
|
end_time = Time.zone.local(year, 12, 31).end_of_day
|
||||||
|
start_time..end_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_conversations_count
|
||||||
|
account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def busiest_day_data
|
||||||
|
daily_counts = account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
||||||
|
.count
|
||||||
|
|
||||||
|
return nil if daily_counts.empty?
|
||||||
|
|
||||||
|
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
||||||
|
|
||||||
|
return nil if count.zero?
|
||||||
|
|
||||||
|
{
|
||||||
|
date: busiest_date.strftime('%b %d'),
|
||||||
|
count: count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def support_personality_data
|
||||||
|
response_time = average_response_time
|
||||||
|
|
||||||
|
return { avg_response_time_seconds: 0 } if response_time.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
avg_response_time_seconds: response_time.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def average_response_time
|
||||||
|
avg_time = account.reporting_events
|
||||||
|
.where(
|
||||||
|
name: 'first_response',
|
||||||
|
user_id: user_id,
|
||||||
|
created_at: year_range
|
||||||
|
)
|
||||||
|
.average(:value)
|
||||||
|
|
||||||
|
avg_time&.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
||||||
|
def show
|
||||||
|
year = params[:year] || 2025
|
||||||
|
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
||||||
|
|
||||||
|
cached_data = Current.user.ui_settings&.dig(cache_key)
|
||||||
|
|
||||||
|
if cached_data.present?
|
||||||
|
render json: cached_data
|
||||||
|
else
|
||||||
|
builder = YearInReviewBuilder.new(
|
||||||
|
account: Current.account,
|
||||||
|
user_id: Current.user.id,
|
||||||
|
year: year
|
||||||
|
)
|
||||||
|
|
||||||
|
data = builder.build
|
||||||
|
|
||||||
|
ui_settings = Current.user.ui_settings || {}
|
||||||
|
ui_settings[cache_key] = data
|
||||||
|
Current.user.update(ui_settings: ui_settings)
|
||||||
|
|
||||||
|
render json: data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
app/javascript/dashboard/api/yearInReview.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class YearInReviewAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(year) {
|
||||||
|
return axios.get(`${this.url}`, {
|
||||||
|
params: { year },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new YearInReviewAPI();
|
||||||
@@ -14,6 +14,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
|
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
@@ -651,6 +652,7 @@ const menuItems = computed(() => {
|
|||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||||
/>
|
/>
|
||||||
|
<YearInReviewBanner />
|
||||||
<SidebarChangelogCard
|
<SidebarChangelogCard
|
||||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import Auth from 'dashboard/api/auth';
|
import Auth from 'dashboard/api/auth';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@@ -22,6 +25,8 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||||
@@ -31,6 +36,29 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
'accounts/isFeatureEnabledonAccount'
|
'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 route.query['year-in-review'] === 'true' && isBannerClosed.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openYearInReviewModal = () => {
|
||||||
|
showYearInReviewModal.value = true;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeYearInReviewModal = () => {
|
||||||
|
showYearInReviewModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const showChatSupport = computed(() => {
|
const showChatSupport = computed(() => {
|
||||||
return (
|
return (
|
||||||
isFeatureEnabledonAccount.value(
|
isFeatureEnabledonAccount.value(
|
||||||
@@ -42,6 +70,13 @@ const showChatSupport = computed(() => {
|
|||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
show: showYearInReviewMenuItem.value,
|
||||||
|
showOnCustomBrandedInstance: false,
|
||||||
|
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
||||||
|
icon: 'i-lucide-gift',
|
||||||
|
click: openYearInReviewModal,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
show: showChatSupport.value,
|
show: showChatSupport.value,
|
||||||
showOnCustomBrandedInstance: false,
|
showOnCustomBrandedInstance: false,
|
||||||
@@ -157,4 +192,9 @@ const allowedMenuItems = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</DropdownBody>
|
</DropdownBody>
|
||||||
</DropdownContainer>
|
</DropdownContainer>
|
||||||
|
|
||||||
|
<YearInReviewModal
|
||||||
|
:show="showYearInReviewModal"
|
||||||
|
@close="closeYearInReviewModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<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, {
|
||||||
|
quality: 2,
|
||||||
|
pixelRatio: 1.2,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = dataUrl;
|
||||||
|
await new Promise(resolve => {
|
||||||
|
img.onload = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
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,93 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
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 route = useRoute();
|
||||||
|
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(
|
||||||
|
() =>
|
||||||
|
route.query['year-in-review'] === 'true' &&
|
||||||
|
!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,384 @@
|
|||||||
|
<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 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) {
|
||||||
|
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 = () => {
|
||||||
|
isOpen.value = true;
|
||||||
|
fetchYearInReviewData();
|
||||||
|
playDrumroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSlideBackground = computed(
|
||||||
|
() => slideBackgrounds[currentSlide.value]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareCurrentSlide = async () => {
|
||||||
|
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>
|
||||||
@@ -38,6 +38,7 @@ import teamsSettings from './teamsSettings.json';
|
|||||||
import whatsappTemplates from './whatsappTemplates.json';
|
import whatsappTemplates from './whatsappTemplates.json';
|
||||||
import contentTemplates from './contentTemplates.json';
|
import contentTemplates from './contentTemplates.json';
|
||||||
import mfa from './mfa.json';
|
import mfa from './mfa.json';
|
||||||
|
import yearInReview from './yearInReview.json';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...advancedFilters,
|
...advancedFilters,
|
||||||
@@ -80,4 +81,5 @@ export default {
|
|||||||
...whatsappTemplates,
|
...whatsappTemplates,
|
||||||
...contentTemplates,
|
...contentTemplates,
|
||||||
...mfa,
|
...mfa,
|
||||||
|
...yearInReview,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
"CONTACT_SUPPORT": "Contact support",
|
"CONTACT_SUPPORT": "Contact support",
|
||||||
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
||||||
"PROFILE_SETTINGS": "Profile settings",
|
"PROFILE_SETTINGS": "Profile settings",
|
||||||
|
"YEAR_IN_REVIEW": "Year in Review",
|
||||||
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
||||||
"APPEARANCE": "Change appearance",
|
"APPEARANCE": "Change appearance",
|
||||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||||
|
|||||||
64
app/javascript/dashboard/i18n/locale/en/yearInReview.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"YEAR_IN_REVIEW": {
|
||||||
|
"TITLE": "Year in Review",
|
||||||
|
"LOADING": "Loading your year in review...",
|
||||||
|
"ERROR": "Failed to load year in review",
|
||||||
|
"CLOSE": "Close",
|
||||||
|
"CONVERSATIONS": {
|
||||||
|
"TITLE": "You have handled",
|
||||||
|
"SUBTITLE": "conversations",
|
||||||
|
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
|
||||||
|
"COMPARISON": {
|
||||||
|
"0_50": "You showed up, and that's how every good inbox begins.",
|
||||||
|
"50_100": "You kept the replies flowing and the conversations alive.",
|
||||||
|
"100_500": "You handled serious volume and kept everything on track.",
|
||||||
|
"500_2000": "You kept things moving while the volume kept climbing.",
|
||||||
|
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
|
||||||
|
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BUSIEST_DAY": {
|
||||||
|
"TITLE": "Your busiest day was",
|
||||||
|
"MESSAGE": "{count} conversations that day.",
|
||||||
|
"COMPARISON": {
|
||||||
|
"0_5": "A warm-up lap that barely woke the inbox.",
|
||||||
|
"5_10": "Enough action to justify a second cup of coffee.",
|
||||||
|
"10_25": "Things got busy and the inbox stayed on its toes.",
|
||||||
|
"25_50": "A proper rush that barely broke a sweat.",
|
||||||
|
"50_100": "Controlled chaos, handled like a normal Tuesday.",
|
||||||
|
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
|
||||||
|
"500_PLUS": "The inbox lost all chill and never slowed down."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PERSONALITY": {
|
||||||
|
"TITLE": "Your support personality is",
|
||||||
|
"MESSAGES": {
|
||||||
|
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
|
||||||
|
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
|
||||||
|
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
|
||||||
|
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"THANK_YOU": {
|
||||||
|
"TITLE": "Congratulations on surviving the inbox of {year}.",
|
||||||
|
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
|
||||||
|
},
|
||||||
|
"SHARE_MODAL": {
|
||||||
|
"TITLE": "Share Your Year in Review",
|
||||||
|
"PREPARING": "Preparing your image...",
|
||||||
|
"DOWNLOAD": "Download",
|
||||||
|
"SHARE_TITLE": "My {year} Year in Review",
|
||||||
|
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
|
||||||
|
"BRANDING": "Made with Chatwoot"
|
||||||
|
},
|
||||||
|
"BANNER": {
|
||||||
|
"TITLE": "Your {year} Year in Review is here",
|
||||||
|
"BUTTON": "See your impact"
|
||||||
|
},
|
||||||
|
"NAVIGATION": {
|
||||||
|
"PREVIOUS": "Previous",
|
||||||
|
"NEXT": "Next",
|
||||||
|
"SHARE": "Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -425,6 +425,7 @@ Rails.application.routes.draw do
|
|||||||
get :bot_metrics
|
get :bot_metrics
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resource :year_in_review, only: [:show]
|
||||||
resources :live_reports, only: [] do
|
resources :live_reports, only: [] do
|
||||||
collection do
|
collection do
|
||||||
get :conversation_metrics
|
get :conversation_metrics
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"json-logic-js": "^2.0.5",
|
"json-logic-js": "^2.0.5",
|
||||||
|
|||||||
47
pnpm-lock.yaml
generated
@@ -127,6 +127,12 @@ importers:
|
|||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.10.0
|
specifier: ^11.10.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
|
html2canvas:
|
||||||
|
specifier: ^1.4.1
|
||||||
|
version: 1.4.1
|
||||||
idb:
|
idb:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
@@ -1672,6 +1678,10 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
@@ -1918,6 +1928,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.4
|
postcss: ^8.4
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||||
|
|
||||||
css-prefers-color-scheme@8.0.2:
|
css-prefers-color-scheme@8.0.2:
|
||||||
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
|
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
|
||||||
engines: {node: ^14 || ^16 || >=18}
|
engines: {node: ^14 || ^16 || >=18}
|
||||||
@@ -2686,6 +2699,13 @@ packages:
|
|||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
htmlparser2@8.0.2:
|
htmlparser2@8.0.2:
|
||||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||||
|
|
||||||
@@ -4217,6 +4237,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
|
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||||
|
|
||||||
text-table@0.2.0:
|
text-table@0.2.0:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
|
|
||||||
@@ -4413,6 +4436,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||||
|
|
||||||
video.js@7.18.1:
|
video.js@7.18.1:
|
||||||
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
|
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
|
||||||
|
|
||||||
@@ -6248,6 +6274,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
@@ -6530,6 +6558,10 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.1.1
|
postcss-selector-parser: 6.1.1
|
||||||
postcss-value-parser: 4.2.0
|
postcss-value-parser: 4.2.0
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
|
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.4.47
|
postcss: 8.4.47
|
||||||
@@ -7464,6 +7496,13 @@ snapshots:
|
|||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
css-line-break: 2.1.0
|
||||||
|
text-segmentation: 1.0.3
|
||||||
|
|
||||||
htmlparser2@8.0.2:
|
htmlparser2@8.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
@@ -9158,6 +9197,10 @@ snapshots:
|
|||||||
glob: 10.4.5
|
glob: 10.4.5
|
||||||
minimatch: 9.0.5
|
minimatch: 9.0.5
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
text-table@0.2.0: {}
|
text-table@0.2.0: {}
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
@@ -9358,6 +9401,10 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
|
||||||
video.js@7.18.1:
|
video.js@7.18.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.25.6
|
'@babel/runtime': 7.25.6
|
||||||
|
|||||||
BIN
public/assets/images/dashboard/year-in-review/double-quotes.png
Normal file
|
After Width: | Height: | Size: 723 B |
|
After Width: | Height: | Size: 37 KiB |
BIN
public/assets/images/dashboard/year-in-review/first-frame-bg.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 112 KiB |
BIN
public/audio/dashboard/drumroll.mp3
Normal file
64
spec/builders/year_in_review_builder_spec.rb
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe YearInReviewBuilder, type: :model do
|
||||||
|
subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
let(:year) { 2025 }
|
||||||
|
|
||||||
|
describe '#build' do
|
||||||
|
context 'when there is no data for the year' do
|
||||||
|
it 'returns empty aggregates' do
|
||||||
|
result = builder.build
|
||||||
|
|
||||||
|
expect(result[:year]).to eq(year)
|
||||||
|
expect(result[:total_conversations]).to eq(0)
|
||||||
|
expect(result[:busiest_day]).to be_nil
|
||||||
|
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when there is data for the year' do
|
||||||
|
let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) }
|
||||||
|
let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:conversation, account: account, assignee: user, created_at: busiest_date)
|
||||||
|
create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour)
|
||||||
|
create(:conversation, account: account, assignee: user, created_at: other_date)
|
||||||
|
|
||||||
|
create(
|
||||||
|
:reporting_event,
|
||||||
|
account: account,
|
||||||
|
user: user,
|
||||||
|
name: 'first_response',
|
||||||
|
value: 12.7,
|
||||||
|
created_at: busiest_date
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns total conversations count' do
|
||||||
|
expect(builder.build[:total_conversations]).to eq(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns busiest day data' do
|
||||||
|
expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns support personality data' do
|
||||||
|
expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'scopes data to the provided year' do
|
||||||
|
create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1))
|
||||||
|
create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1))
|
||||||
|
|
||||||
|
result = builder.build
|
||||||
|
|
||||||
|
expect(result[:total_conversations]).to eq(3)
|
||||||
|
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||