Compare commits

..
Author SHA1 Message Date
Muhsin 473dc978e0 feat: sla business hours 2025-12-16 13:01:10 +05:30
PranavandGitHub bb8bafe3dc 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"
/>
2025-12-15 17:24:45 -08:00
Sojan JoseandGitHub d2ba9a2ad3 feat(enterprise): add voice conference API (#13064)
The backend APIs for the voice call channel 
ref: #11602
2025-12-15 15:11:59 -08:00
50 changed files with 2732 additions and 55 deletions
+2
View File
@@ -48,6 +48,7 @@
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -78,3 +79,4 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
+74
View 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
@@ -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();
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -40,9 +41,17 @@ const slaStatusText = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.slaPolicy,
props.conversation?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
chat: props.conversation,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
@@ -14,6 +14,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -651,6 +652,7 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,11 +1,14 @@
<script setup>
import { computed } from 'vue';
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -22,6 +25,8 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -31,6 +36,29 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -42,6 +70,13 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -157,4 +192,9 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</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>
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -58,9 +59,17 @@ const groupClass = computed(() => {
});
const updateSlaStatus = () => {
const businessHoursConfig = getBusinessHoursConfig(
appliedSLA.value?.sla_policy,
props.chat?.inbox
);
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
options: businessHoursConfig
? { businessHours: businessHoursConfig }
: undefined,
});
};
@@ -0,0 +1,66 @@
/**
* Helper functions for SLA business hours configuration
*/
/**
* Extracts business hours configuration from SLA policy and inbox data
* Supports both camelCase and snake_case property naming conventions
*
* @param {Object} slaPolicy - The SLA policy object
* @param {Object} inbox - The inbox object with working hours configuration
* @returns {Object|null} Business hours configuration for utils package, or null if not applicable
*/
export const getBusinessHoursConfig = (slaPolicy, inbox) => {
// Handle both camelCase and snake_case property names
const onlyDuringBusinessHours =
slaPolicy?.only_during_business_hours ?? slaPolicy?.onlyDuringBusinessHours;
const workingHoursEnabled =
inbox?.working_hours_enabled ?? inbox?.workingHoursEnabled;
if (!onlyDuringBusinessHours || !workingHoursEnabled) {
return null;
}
// Convert working hours format to match utils expectation
const workingHours = {};
const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
// Handle both camelCase and snake_case working hours arrays
const inboxWorkingHours = inbox.working_hours ?? inbox.workingHours;
if (inboxWorkingHours) {
inboxWorkingHours.forEach(dayConfig => {
// Handle both property naming conventions
const dayOfWeek = dayConfig.day_of_week ?? dayConfig.dayOfWeek;
const closedAllDay = dayConfig.closed_all_day ?? dayConfig.closedAllDay;
const openHour = dayConfig.open_hour ?? dayConfig.openHour;
const openMinutes = dayConfig.open_minutes ?? dayConfig.openMinutes;
const closeHour = dayConfig.close_hour ?? dayConfig.closeHour;
const closeMinutes = dayConfig.close_minutes ?? dayConfig.closeMinutes;
const dayName = dayMap[dayOfWeek];
if (closedAllDay) {
workingHours[dayName] = null;
} else {
// Convert to HH:MM format
const startHour = String(openHour || 0).padStart(2, '0');
const startMin = String(openMinutes || 0).padStart(2, '0');
const endHour = String(closeHour || 0).padStart(2, '0');
const endMin = String(closeMinutes || 0).padStart(2, '0');
workingHours[dayName] = {
start: `${startHour}:${startMin}`,
finish: `${endHour}:${endMin}`,
};
}
});
}
return {
working_hours_enabled: workingHoursEnabled,
timezone: inbox.timezone || 'UTC',
working_hours: workingHours,
only_during_business_hours: onlyDuringBusinessHours,
};
};
@@ -0,0 +1,386 @@
import { getBusinessHoursConfig } from '../slaHelper';
describe('slaHelper', () => {
describe('getBusinessHoursConfig', () => {
const mockWorkingHours = [
{
day_of_week: 1, // Monday
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 30,
closed_all_day: false,
},
{
day_of_week: 2, // Tuesday
open_hour: 8,
open_minutes: 30,
close_hour: 18,
close_minutes: 0,
closed_all_day: false,
},
{
day_of_week: 0, // Sunday
closed_all_day: true,
},
];
const mockWorkingHoursCamelCase = [
{
dayOfWeek: 1, // Monday
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 30,
closedAllDay: false,
},
{
dayOfWeek: 2, // Tuesday
openHour: 8,
openMinutes: 30,
closeHour: 18,
closeMinutes: 0,
closedAllDay: false,
},
{
dayOfWeek: 0, // Sunday
closedAllDay: true,
},
];
describe('when business hours are not required', () => {
it('returns null when SLA policy does not require business hours', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when inbox does not have working hours enabled', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: false,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when both conditions are false', () => {
const slaPolicy = { only_during_business_hours: false };
const inbox = { working_hours_enabled: false };
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toBeNull();
});
it('returns null when slaPolicy is null', () => {
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
};
const result = getBusinessHoursConfig(null, inbox);
expect(result).toBeNull();
});
it('returns null when inbox is null', () => {
const slaPolicy = { only_during_business_hours: true };
const result = getBusinessHoursConfig(slaPolicy, null);
expect(result).toBeNull();
});
});
describe('when business hours are required - snake_case properties', () => {
it('converts working hours correctly with snake_case properties', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours,
timezone: 'America/New_York',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/New_York',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
it('handles missing timezone by defaulting to UTC', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: mockWorkingHours.slice(0, 1), // Just Monday
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.timezone).toBe('UTC');
});
it('handles zero hours and minutes correctly', () => {
const workingHoursWithZeros = [
{
day_of_week: 1,
open_hour: 0,
open_minutes: 0,
close_hour: 0,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithZeros,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
});
describe('when business hours are required - camelCase properties', () => {
it('converts working hours correctly with camelCase properties', () => {
const slaPolicy = { onlyDuringBusinessHours: true };
const inbox = {
workingHoursEnabled: true,
workingHours: mockWorkingHoursCamelCase,
timezone: 'America/Los_Angeles',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).toEqual({
working_hours_enabled: true,
timezone: 'America/Los_Angeles',
working_hours: {
sun: null, // Closed all day
mon: {
start: '09:00',
finish: '17:30',
},
tue: {
start: '08:30',
finish: '18:00',
},
},
only_during_business_hours: true,
});
});
});
describe('mixed property naming conventions', () => {
it('handles mixed snake_case and camelCase properties', () => {
const slaPolicy = {
only_during_business_hours: true, // snake_case
onlyDuringBusinessHours: false, // This should be ignored due to nullish coalescing
};
const inbox = {
working_hours_enabled: true, // snake_case
workingHours: mockWorkingHoursCamelCase, // camelCase
timezone: 'UTC',
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result).not.toBeNull();
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
});
it('prioritizes snake_case over camelCase when both exist', () => {
const slaPolicy = {
only_during_business_hours: true,
onlyDuringBusinessHours: false,
};
const inbox = {
working_hours_enabled: true,
workingHoursEnabled: false,
working_hours: mockWorkingHours,
workingHours: mockWorkingHoursCamelCase,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.only_during_business_hours).toBe(true);
expect(result.working_hours_enabled).toBe(true);
// Should use snake_case working_hours
expect(result.working_hours.mon.start).toBe('09:00');
});
});
describe('edge cases', () => {
it('handles empty working hours array', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: [],
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles missing working hours property', () => {
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
// No working_hours property
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({});
});
it('handles undefined values in working hours', () => {
const workingHoursWithUndefined = [
{
day_of_week: 1,
open_hour: undefined,
open_minutes: undefined,
close_hour: undefined,
close_minutes: undefined,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursWithUndefined,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.mon).toEqual({
start: '00:00',
finish: '00:00',
});
});
it('pads single digit hours and minutes correctly', () => {
const workingHoursSingleDigit = [
{
day_of_week: 3, // Wednesday
open_hour: 9,
open_minutes: 5,
close_hour: 5,
close_minutes: 0,
closed_all_day: false,
},
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: workingHoursSingleDigit,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours.wed).toEqual({
start: '09:05',
finish: '05:00',
});
});
it('handles all days of the week correctly', () => {
const fullWeekWorkingHours = [
{ day_of_week: 0, closed_all_day: true }, // Sunday
{
day_of_week: 1,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Monday
{
day_of_week: 2,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Tuesday
{
day_of_week: 3,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Wednesday
{
day_of_week: 4,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Thursday
{
day_of_week: 5,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
closed_all_day: false,
}, // Friday
{ day_of_week: 6, closed_all_day: true }, // Saturday
];
const slaPolicy = { only_during_business_hours: true };
const inbox = {
working_hours_enabled: true,
working_hours: fullWeekWorkingHours,
};
const result = getBusinessHoursConfig(slaPolicy, inbox);
expect(result.working_hours).toEqual({
sun: null,
mon: { start: '09:00', finish: '17:00' },
tue: { start: '09:00', finish: '17:00' },
wed: { start: '09:00', finish: '17:00' },
thu: { start: '09:00', finish: '17:00' },
fri: { start: '09:00', finish: '17:00' },
sat: null,
});
});
});
});
});
@@ -38,6 +38,7 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -80,4 +81,5 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};
@@ -234,6 +234,7 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
"YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
@@ -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"
}
}
}
@@ -46,7 +46,8 @@ export default {
}),
showAttachment() {
return (
(this.shouldShowFilePicker || this.hasAttachmentsEnabled) &&
this.shouldShowFilePicker &&
this.hasAttachmentsEnabled &&
this.userInput.length === 0
);
},
+10 -7
View File
@@ -203,9 +203,15 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -419,6 +425,7 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resource :year_in_review, only: [:show]
resources :live_reports, only: [] do
collection do
get :conversation_metrics
@@ -550,13 +557,9 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
resource :voice, only: [], controller: 'voice' do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
post 'conference_status/:phone', action: :conference_status
end
end
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
end
end
@@ -0,0 +1,58 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end
@@ -151,9 +151,8 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
"#{host}/twilio/voice/conference_status/#{phone_digits}"
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
+7 -6
View File
@@ -42,11 +42,13 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:)
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
@@ -56,12 +58,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
@@ -87,7 +89,6 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
@@ -1,5 +1,6 @@
class Sla::EvaluateAppliedSlaService
pattr_initialize [:applied_sla!]
include ReportingEventHelper
def perform
check_sla_thresholds
@@ -21,12 +22,58 @@ class Sla::EvaluateAppliedSlaService
end
end
def should_use_business_hours?(sla_policy, inbox)
sla_policy.only_during_business_hours? && inbox.working_hours_enabled?
end
# Calculates the SLA threshold deadline considering business hours if enabled.
#
# This method determines when an SLA will be breached by adding the threshold duration
# to the start time. If business hours are enabled, it only counts time during working hours,
# automatically skipping weekends and after-hours periods.
def calculate_threshold_deadline(start_time, threshold_seconds, inbox, sla_policy)
# Fall back to simple calendar time calculation if business hours not enabled
return start_time.to_i + threshold_seconds unless should_use_business_hours?(sla_policy, inbox)
# Configure the working_hours gem with inbox-specific schedule and timezone
configure_working_hours_for_calculation(inbox)
# Convert start time to inbox timezone for accurate business hours calculation
start_time_in_timezone = start_time.in_time_zone(inbox.timezone).to_time
# Determine effective start time: if outside business hours, advance to next working time
# Example: Saturday 6 PM conversation would start counting Monday 9 AM
effective_start_time = if start_time_in_timezone.in_working_hours?
start_time_in_timezone
else
WorkingHours.next_working_time(start_time_in_timezone)
end
# Add working time duration
# This automatically skips non-working hours, weekends, and holidays
deadline = effective_start_time + threshold_seconds.working.seconds
deadline.to_i
end
def configure_working_hours_for_calculation(inbox)
inbox_working_hours = configure_working_hours(inbox.working_hours)
return if inbox_working_hours.blank?
WorkingHours::Config.working_hours = inbox_working_hours
WorkingHours::Config.time_zone = inbox.timezone
end
def still_within_threshold?(threshold)
Time.zone.now.to_i < threshold
end
def check_first_response_time_threshold(applied_sla, conversation, sla_policy)
threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.first_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if first_reply_was_within_threshold?(conversation, threshold)
return if still_within_threshold?(threshold)
@@ -43,7 +90,12 @@ class Sla::EvaluateAppliedSlaService
# Waiting on customer response, no need to check next response time threshold
return if conversation.waiting_since.blank?
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.waiting_since,
sla_policy.next_response_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'nrt')
@@ -61,7 +113,12 @@ class Sla::EvaluateAppliedSlaService
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
return if conversation.resolved?
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
threshold = calculate_threshold_deadline(
conversation.created_at,
sla_policy.resolution_time_threshold.to_i,
conversation.inbox,
sla_policy
)
return if still_within_threshold?(threshold)
handle_missed_sla(applied_sla, 'rt')
@@ -0,0 +1,52 @@
class Voice::Provider::Twilio::Adapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
call = twilio_client.calls.create(**call_params(to))
{
provider: 'twilio',
call_sid: call.sid,
status: call.status,
call_direction: 'outbound',
requires_agent_join: true,
agent_id: agent_id,
conference_sid: conference_sid
}
end
private
def call_params(to)
phone_digits = @channel.phone_number.delete_prefix('+')
{
from: @channel.phone_number,
to: to,
url: twilio_call_twiml_url(phone_digits),
status_callback: twilio_call_status_url(phone_digits),
status_callback_event: %w[
initiated ringing answered completed failed busy no-answer canceled
],
status_callback_method: 'POST'
}
end
def twilio_call_twiml_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
end
def twilio_call_status_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end
end
@@ -0,0 +1,46 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -0,0 +1,62 @@
class Voice::Provider::Twilio::TokenService
pattr_initialize [:inbox!, :user!, :account!]
def generate
{
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@identity ||= "agent-#{user.id}-account-#{account.id}"
end
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: account.id,
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
@@ -1,32 +0,0 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end
+2
View File
@@ -69,6 +69,8 @@
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
+47
View File
@@ -127,6 +127,12 @@ importers:
highlight.js:
specifier: ^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:
specifier: ^8.0.0
version: 8.0.0
@@ -1672,6 +1678,10 @@ packages:
balanced-match@1.0.2:
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:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1918,6 +1928,9 @@ packages:
peerDependencies:
postcss: ^8.4
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-prefers-color-scheme@8.0.2:
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
engines: {node: ^14 || ^16 || >=18}
@@ -2686,6 +2699,13 @@ packages:
html-escaper@2.0.2:
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:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -4217,6 +4237,9 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4413,6 +4436,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
video.js@7.18.1:
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
@@ -6248,6 +6274,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6530,6 +6558,10 @@ snapshots:
postcss-selector-parser: 6.1.1
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):
dependencies:
postcss: 8.4.47
@@ -7464,6 +7496,13 @@ snapshots:
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:
dependencies:
domelementtype: 2.3.0
@@ -9158,6 +9197,10 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -9358,6 +9401,10 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
video.js@7.18.1:
dependencies:
'@babel/runtime': 7.25.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
@@ -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
@@ -0,0 +1,142 @@
require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
let(:conference_service) do
instance_double(
Voice::Provider::Twilio::ConferenceService,
ensure_conference_sid: 'CF123',
mark_agent_joined: true,
end_conference: true
)
end
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
allow(voice_grant).to receive(:outgoing_application_sid=)
allow(voice_grant).to receive(:outgoing_application_params=)
allow(voice_grant).to receive(:incoming_allow=)
allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
end
describe 'GET /conference/token' do
context 'when unauthenticated' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'returns token payload' do
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['token']).to eq('jwt-token')
expect(body['account_id']).to eq(account.id)
expect(body['inbox_id']).to eq(voice_inbox.id)
end
end
end
describe 'POST /conference' do
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'creates conference and sets identifier' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
end
it 'does not allow accessing conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content)
end
end
end
describe 'DELETE /conference' do
context 'when unauthenticated' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
it 'ends conference and returns success' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
end
it 'does not allow ending conferences for conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id }
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -102,8 +102,9 @@ RSpec.describe Captain::CustomTool, type: :model do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
enabled_ids = described_class.enabled.pluck(:id)
expect(enabled_ids).to include(enabled_tool.id)
expect(enabled_ids).not_to include(disabled_tool.id)
end
end
end
@@ -222,4 +222,278 @@ RSpec.describe Sla::EvaluateAppliedSlaService do
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
describe 'Business Hours SLA evaluation' do
let!(:inbox) { create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC') }
let!(:conversation) do
create(:conversation,
created_at: '15.05.2024 09:00'.to_datetime, assignee: user_1,
account: sla_policy.account,
sla_policy: sla_policy,
inbox: inbox)
end
let!(:applied_sla) { conversation.applied_sla }
before do
# Configure business hours: Mon-Fri 9 AM - 5 PM UTC
(1..5).each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0)
end
# Weekend closed
[0, 6].each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
closed_all_day: true)
end
Time.zone = 'UTC'
travel_to '15.05.2024 10:00'.to_datetime
applied_sla.sla_policy.update(only_during_business_hours: true)
end
context 'when business hours are enabled and SLA policy requires it' do
context 'when evaluating first response time with business hours' do
before { applied_sla.sla_policy.update(first_response_time_threshold: 2.hours) }
it 'does not miss FRT when within business hours deadline' do
# Conversation created at 9 AM
# 2 hour FRT would be due at 1 PM (within business hours)
# Current time is 10 AM
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses FRT when business hours deadline is exceeded' do
travel_to '15.05.2024 13:01'.to_datetime
# Conversation created at 9 AM
# 2 hour business hours FRT would be due at 1 PM
# Current time is 1:01 PM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'extends deadline over weekend correctly' do
# Create conversation on Friday at 4 PM
conversation.update(created_at: '10.05.2024 16:00'.to_datetime)
travel_to '13.05.2024 09:59'.to_datetime # Monday 9:59 AM
# 2 hour FRT from Friday 4 PM should be due Monday 10 AM
# (1 hour Friday + 1 hour Monday)
# Current time Monday 9:59 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when evaluating next response time with business hours' do
before do
applied_sla.sla_policy.update(next_response_time_threshold: 1.hour)
conversation.update(
first_reply_created_at: '15.05.2024 09:30'.to_datetime,
waiting_since: '15.05.2024 10:00'.to_datetime
)
end
it 'calculates NRT deadline correctly in business hours' do
travel_to '15.05.2024 10:30'.to_datetime
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
# Current time 10:30 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses NRT when business hours deadline is exceeded' do
travel_to '15.05.2024 11:01'.to_datetime
# Customer replied at 10 AM, 1 hour NRT should be due at 11 AM
# Current time 11:01 AM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when evaluating resolution time with business hours' do
before { applied_sla.sla_policy.update(resolution_time_threshold: 4.hours) }
it 'calculates RT deadline correctly in business hours' do
travel_to '15.05.2024 12:00'.to_datetime
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
# Current time 12 PM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'misses RT when business hours deadline is exceeded' do
travel_to '15.05.2024 15:01'.to_datetime
# Conversation created at 9 AM, 4 hour RT should be due at 3 PM
# Current time 3:01 PM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when SLA spans across multiple business days' do
before { applied_sla.sla_policy.update(resolution_time_threshold: 10.hours) }
it 'correctly calculates deadline across multiple business days' do
# Conversation created Wednesday 4 PM
conversation.update(created_at: '15.05.2024 16:00'.to_datetime)
travel_to '16.05.2024 11:00'.to_datetime # Thursday 11 AM
# 10 hour RT: Wed 4-5 PM (1h) + Thu 9-11 AM (2h) = 3h elapsed, 7h remaining
# Deadline should be Friday at 2 PM (Thu 9-5 = 8h total = 7h remaining)
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when SLA starts near end of business day' do
before do
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
conversation.update(created_at: '15.05.2024 16:30'.to_datetime) # 4:30 PM
end
it 'extends deadline to next business day' do
travel_to '16.05.2024 10:29'.to_datetime # Next day 10:29 AM
# Started 4:30 PM (30 min before close) + 2 hours = 1.5 hours into next day
# Deadline should be 10:30 AM next day
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
end
context 'when business hours are disabled on inbox' do
before do
inbox.update(working_hours_enabled: false)
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
# Current time 11:01 AM - should miss with calendar time
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when SLA policy has business hours disabled' do
before do
applied_sla.sla_policy.update(
only_during_business_hours: false,
first_response_time_threshold: 2.hours
)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# Conversation created at 9 AM, 2 hour FRT would be due at 11 AM (calendar time)
# Current time 11:01 AM - should miss with calendar time
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
context 'when conversation starts outside business hours' do
before do
conversation.update(created_at: '18.05.2024 18:00'.to_datetime)
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
travel_to '20.05.2024 10:00'.to_datetime # Monday 10 AM
# Saturday 6 PM
end
it 'starts counting from next business hours' do
# Conversation created Saturday 6 PM (outside business hours)
# 2 hour FRT should start counting from Monday 9 AM
# Deadline would be Monday 11 AM
# Current time Monday 10 AM - should not miss yet
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when handling different timezones' do
before do
inbox.update(timezone: 'America/New_York')
# Update working hours for EST timezone
inbox.working_hours.destroy_all
(1..5).each do |day|
create(:working_hour,
inbox: inbox,
day_of_week: day,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0)
end
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'correctly handles timezone conversions' do
# Conversation created at 9 AM EST (2 PM UTC)
conversation.update(created_at: Time.zone.parse('15.05.2024 09:00 EST'))
# Test at 10:59 AM EST (3:59 PM UTC) - should not miss 2 hour SLA yet
travel_to Time.zone.parse('15.05.2024 10:59 EST')
# Should calculate business hours in the inbox timezone
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).not_to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active')
end
end
context 'when no working hours are configured' do
before do
inbox.working_hours.destroy_all
applied_sla.sla_policy.update(first_response_time_threshold: 2.hours)
end
it 'falls back to calendar time calculation' do
travel_to '15.05.2024 11:01'.to_datetime
# No working hours configured, should fall back to calendar time
# Conversation at 9 AM + 2 hours = 11 AM deadline
# Current time 11:01 AM - should miss
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn)
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
end
end
@@ -0,0 +1,43 @@
require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
let(:call_instance) do
instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
end
let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
it 'initiates an outbound call with expected params' do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
phone_digits = channel.phone_number.delete_prefix('+')
expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
expect(calls_double).to have_received(:create).with(hash_including(
from: channel.phone_number,
to: '+15550001111',
url: expected_url,
status_callback: expected_status_callback,
status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
'canceled')
))
expect(result[:call_sid]).to eq('CA123')
expect(result[:conference_sid]).to eq('CF999')
expect(result[:agent_id]).to eq(42)
expect(result[:call_direction]).to eq('outbound')
end
end
@@ -0,0 +1,60 @@
require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
service.end_conference
expect(conf_context).to have_received(:update).with(status: 'completed')
end
end
end
@@ -0,0 +1,33 @@
require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
allow(voice_grant).to receive(:outgoing_application_sid=)
allow(voice_grant).to receive(:outgoing_application_params=)
allow(voice_grant).to receive(:incoming_allow=)
end
it 'returns a token payload with expected keys' do
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
payload = described_class.new(inbox: inbox, user: user, account: account).generate
expect(payload[:token]).to eq('jwt-token')
expect(payload[:identity]).to include("agent-#{user.id}")
expect(payload[:inbox_id]).to eq(inbox.id)
expect(payload[:account_id]).to eq(account.id)
expect(payload[:voice_enabled]).to be true
expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
end
end
+1 -1
View File
@@ -41,7 +41,7 @@ const tailwindConfig = {
fontFamily: {
sans: defaultSansFonts,
inter: ['Inter', ...defaultSansFonts],
interDisplay: ['Inter Display', ...defaultSansFonts],
interDisplay: ['InterDisplay', ...defaultSansFonts],
},
typography: {
bubble: {