fix: heatmap colors for dark mode [CW-3241] (#9278)
* feat: add new heatmap colors * fix: loader * fix: move new styles to tailwind * feat: update tw classes * refactor: update styles * feat: add useI18n composable * feat: use composition api * fix: empty div * chore: don't import defineProps Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
32
app/javascript/dashboard/composables/useI18n.js
Normal file
32
app/javascript/dashboard/composables/useI18n.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { computed, getCurrentInstance } from 'vue';
|
||||||
|
import Vue from 'vue';
|
||||||
|
import VueI18n from 'vue-i18n';
|
||||||
|
|
||||||
|
let i18nInstance = VueI18n;
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
if (!i18nInstance) throw new Error('vue-i18n not initialized');
|
||||||
|
|
||||||
|
const i18n = i18nInstance;
|
||||||
|
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const vm = instance?.proxy || instance || new Vue({});
|
||||||
|
|
||||||
|
const locale = computed({
|
||||||
|
get() {
|
||||||
|
return i18n.locale;
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
i18n.locale = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
t: vm.$t.bind(vm),
|
||||||
|
tc: vm.$tc.bind(vm),
|
||||||
|
d: vm.$d.bind(vm),
|
||||||
|
te: vm.$te.bind(vm),
|
||||||
|
n: vm.$n.bind(vm),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,308 +1,173 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import getDay from 'date-fns/getDay';
|
||||||
|
|
||||||
|
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const props = defineProps({
|
||||||
|
heatData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedData = computed(() => {
|
||||||
|
return groupHeatmapByDay(props.heatData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const quantileRange = computed(() => {
|
||||||
|
const flattendedData = props.heatData.map(data => data.value);
|
||||||
|
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCountTooltip(value) {
|
||||||
|
if (!value) {
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 1) {
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||||
|
count: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||||
|
count: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
return format(new Date(dateString), 'MMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayOfTheWeek(date) {
|
||||||
|
const dayIndex = getDay(date);
|
||||||
|
const days = [
|
||||||
|
t('DAYS_OF_WEEK.SUNDAY'),
|
||||||
|
t('DAYS_OF_WEEK.MONDAY'),
|
||||||
|
t('DAYS_OF_WEEK.TUESDAY'),
|
||||||
|
t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||||
|
t('DAYS_OF_WEEK.THURSDAY'),
|
||||||
|
t('DAYS_OF_WEEK.FRIDAY'),
|
||||||
|
t('DAYS_OF_WEEK.SATURDAY'),
|
||||||
|
];
|
||||||
|
return days[dayIndex];
|
||||||
|
}
|
||||||
|
function getHeatmapLevelClass(value) {
|
||||||
|
if (!value)
|
||||||
|
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||||
|
|
||||||
|
let level = [...quantileRange.value, Infinity].findIndex(
|
||||||
|
range => value <= range && value > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (level > 6) level = 5;
|
||||||
|
|
||||||
|
if (level === 0) {
|
||||||
|
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
'bg-woot-50 dark:bg-woot-800/40 dark:outline-woot-800/80',
|
||||||
|
'bg-woot-100 dark:bg-woot-800/30 dark:outline-woot-800/80',
|
||||||
|
'bg-woot-200 dark:bg-woot-500/40 dark:outline-woot-700/80',
|
||||||
|
'bg-woot-300 dark:bg-woot-500/60 dark:outline-woot-600/80',
|
||||||
|
'bg-woot-600 dark:bg-woot-500/80 dark:outline-woot-500/80',
|
||||||
|
'bg-woot-800 dark:bg-woot-500 dark:outline-woot-400/80',
|
||||||
|
];
|
||||||
|
|
||||||
|
return classes[level - 1];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="heatmap-container">
|
<div
|
||||||
|
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||||
|
>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="heatmap-labels">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in 7"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="loading-cell heatmap-axis-label"
|
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="heatmap-grid">
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
<div v-for="ii in 7" :key="ii" class="heatmap-grid-row">
|
<div
|
||||||
<div v-for="jj in 24" :key="jj" class="heatmap-tile loading-cell">
|
v-for="ii in 7"
|
||||||
<div class="heatmap-tile__label loading-cell" />
|
:key="ii"
|
||||||
</div>
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="jj in 24"
|
||||||
|
:key="jj"
|
||||||
|
class="w-full h-8 rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="heatmap-timeline" />
|
<div />
|
||||||
<div class="heatmap-markers">
|
<div
|
||||||
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} – {{ ii }}</div>
|
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="ii in 24"
|
||||||
|
:key="ii"
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ ii - 1 }} – {{ ii }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="heatmap-labels">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-for="dateKey in processedData.keys()"
|
v-for="dateKey in processedData.keys()"
|
||||||
:key="dateKey"
|
:key="dateKey"
|
||||||
class="heatmap-axis-label"
|
class="h-8 min-w-[70px] text-slate-800 dark:text-slate-200 text-[10px] font-semibold flex flex-col items-end justify-center"
|
||||||
>
|
>
|
||||||
{{ getDayOfTheWeek(new Date(dateKey)) }}
|
{{ getDayOfTheWeek(new Date(dateKey)) }}
|
||||||
<time>{{ formatDate(dateKey) }}</time>
|
<time class="font-normal text-slate-700 dark:text-slate-200">
|
||||||
|
{{ formatDate(dateKey) }}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="heatmap-grid">
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
<div
|
<div
|
||||||
v-for="dateKey in processedData.keys()"
|
v-for="dateKey in processedData.keys()"
|
||||||
:key="dateKey"
|
:key="dateKey"
|
||||||
class="heatmap-grid-row"
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="data in processedData.get(dateKey)"
|
v-for="data in processedData.get(dateKey)"
|
||||||
:key="data.timestamp"
|
:key="data.timestamp"
|
||||||
v-tooltip.top="getCountTooltip(data.value)"
|
v-tooltip.top="getCountTooltip(data.value)"
|
||||||
class="heatmap-tile"
|
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1 shadow-black"
|
||||||
:class="getHeatmapLevelClass(data.value)"
|
:class="getHeatmapLevelClass(data.value)"
|
||||||
>
|
/>
|
||||||
<div class="heatmap-tile__label" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="heatmap-timeline" />
|
<div />
|
||||||
<div class="heatmap-markers">
|
<div
|
||||||
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} – {{ ii }}</div>
|
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="ii in 24"
|
||||||
|
:key="ii"
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ ii - 1 }} – {{ ii }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
|
||||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
|
||||||
import format from 'date-fns/format';
|
|
||||||
import getDay from 'date-fns/getDay';
|
|
||||||
|
|
||||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Heatmap',
|
|
||||||
props: {
|
|
||||||
heatData: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
processedData() {
|
|
||||||
return groupHeatmapByDay(this.heatData);
|
|
||||||
},
|
|
||||||
quantileRange() {
|
|
||||||
const flattendedData = this.heatData.map(data => data.value);
|
|
||||||
return getQuantileIntervals(
|
|
||||||
flattendedData,
|
|
||||||
[0.2, 0.4, 0.6, 0.8, 0.9, 0.99]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getCountTooltip(value) {
|
|
||||||
if (!value) {
|
|
||||||
return this.$t(
|
|
||||||
'OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 1) {
|
|
||||||
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
|
||||||
count: value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
|
||||||
count: value,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
formatDate(dateString) {
|
|
||||||
return format(new Date(dateString), 'MMM d, yyyy');
|
|
||||||
},
|
|
||||||
getDayOfTheWeek(date) {
|
|
||||||
const dayIndex = getDay(date);
|
|
||||||
const days = [
|
|
||||||
this.$t('DAYS_OF_WEEK.SUNDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.MONDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.TUESDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.WEDNESDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.THURSDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.FRIDAY'),
|
|
||||||
this.$t('DAYS_OF_WEEK.SATURDAY'),
|
|
||||||
];
|
|
||||||
return days[dayIndex];
|
|
||||||
},
|
|
||||||
getHeatmapLevelClass(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
|
|
||||||
const level = [...this.quantileRange, Infinity].findIndex(
|
|
||||||
range => value <= range && value > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (level > 6) {
|
|
||||||
return 'l6';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `l${level}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
$heatmap-colors: (
|
|
||||||
level-1: var(--w-50),
|
|
||||||
level-2: var(--w-100),
|
|
||||||
level-3: var(--w-300),
|
|
||||||
level-4: var(--w-500),
|
|
||||||
level-5: var(--w-700),
|
|
||||||
level-6: var(--w-900),
|
|
||||||
);
|
|
||||||
|
|
||||||
$heatmap-hover-border-color: (
|
|
||||||
level-1: var(--w-25),
|
|
||||||
level-2: var(--w-50),
|
|
||||||
level-3: var(--w-100),
|
|
||||||
level-4: var(--w-300),
|
|
||||||
level-5: var(--w-500),
|
|
||||||
level-6: var(--w-700),
|
|
||||||
);
|
|
||||||
|
|
||||||
$tile-height: 1.875rem;
|
|
||||||
$tile-gap: var(--space-smaller);
|
|
||||||
$container-gap-row: var(--space-one);
|
|
||||||
$container-gap-column: var(--space-two);
|
|
||||||
$marker-height: var(--space-two);
|
|
||||||
|
|
||||||
@mixin heatmap-level($level) {
|
|
||||||
$color: map-get($heatmap-colors, 'level-#{$level}');
|
|
||||||
background-color: $color;
|
|
||||||
&:hover {
|
|
||||||
border: 1px solid map-get($heatmap-hover-border-color, 'level-#{$level}');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.heatmap-container {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-cell {
|
|
||||||
background-color: var(--color-background-light);
|
|
||||||
border: 0px;
|
|
||||||
|
|
||||||
animation: loading-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-container {
|
|
||||||
display: grid;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
gap: $container-gap-row $container-gap-column;
|
|
||||||
grid-template-columns: 80px 1fr;
|
|
||||||
min-height: calc(
|
|
||||||
7 * #{$tile-height} + 6 * #{$tile-gap} + #{$container-gap-row} + #{$marker-height}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-labels {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
gap: $tile-gap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.heatmap-axis-label {
|
|
||||||
height: $tile-height;
|
|
||||||
min-width: 70px;
|
|
||||||
font-size: var(--font-size-micro);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
@apply text-slate-800 dark:text-slate-200;
|
|
||||||
|
|
||||||
time {
|
|
||||||
font-size: var(--font-size-micro);
|
|
||||||
font-weight: var(--font-weight-normal);
|
|
||||||
@apply text-slate-700 dark:text-slate-200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
gap: $tile-gap;
|
|
||||||
min-width: 700px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.heatmap-grid-row {
|
|
||||||
display: grid;
|
|
||||||
gap: $tile-gap;
|
|
||||||
grid-template-columns: repeat(24, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-tile {
|
|
||||||
width: auto;
|
|
||||||
height: $tile-height;
|
|
||||||
border-radius: var(--border-radius-normal);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow-large);
|
|
||||||
|
|
||||||
transform: translateY(-2px);
|
|
||||||
transition:
|
|
||||||
transform 0.2s ease-in-out,
|
|
||||||
box-shadow 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(.l1):not(.l2):not(.l3):not(.l4):not(.l5):not(.l6) {
|
|
||||||
background-color: var(--color-background-light);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: none;
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.l1 {
|
|
||||||
@include heatmap-level(1);
|
|
||||||
}
|
|
||||||
&.l2 {
|
|
||||||
@include heatmap-level(2);
|
|
||||||
}
|
|
||||||
&.l3 {
|
|
||||||
@include heatmap-level(3);
|
|
||||||
}
|
|
||||||
&.l4 {
|
|
||||||
@include heatmap-level(4);
|
|
||||||
}
|
|
||||||
&.l5 {
|
|
||||||
@include heatmap-level(5);
|
|
||||||
}
|
|
||||||
&.l6 {
|
|
||||||
@include heatmap-level(6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.heatmap-markers {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(24, 1fr);
|
|
||||||
gap: $tile-gap;
|
|
||||||
width: 100%;
|
|
||||||
font-size: var(--font-size-nano);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
height: $marker-height;
|
|
||||||
color: var(--color-body);
|
|
||||||
@apply text-slate-800 dark:text-slate-200;
|
|
||||||
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user