feat: Add RTL Support to Widget (#11022)

This PR adds RTL support to the web widget for improved right-to-left language compatibility, updates colors, and cleans up code.

Fixes https://linear.app/chatwoot/issue/CW-4089/rtl-issues-on-widget

https://github.com/chatwoot/chatwoot/issues/9791

Other PR: https://github.com/chatwoot/chatwoot/pull/11016
This commit is contained in:
Sivin Varghese
2025-03-21 22:09:03 +05:30
committed by GitHub
parent e4ea078e52
commit 3a693947b5
76 changed files with 966 additions and 2406 deletions

View File

@@ -4,7 +4,6 @@
@apply inline-block h-6 py-0 px-6 relative align-middle w-6; @apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message { &.message {
@include normal-shadow;
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0; @apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before { &::before {

View File

@@ -1,79 +1,7 @@
@import 'dashboard/assets/scss/variables'; @import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7); $spinner-before-border-color: rgba(255, 255, 255, 0.7);
//borders
@mixin border-nil() {
border-color: transparent;
border: 0;
}
@mixin thin-border($color) {
border: 1px solid $color;
}
@mixin custom-border-bottom($size, $color) {
border-bottom: $size solid $color;
}
@mixin custom-border-top($size, $color) {
border-top: $size solid $color;
}
@mixin border-normal() {
@apply border border-slate-50 dark:border-slate-700;
}
@mixin border-normal-left() {
@apply border-l border-slate-50 dark:border-slate-700;
}
@mixin border-normal-top() {
@apply border-t border-slate-50 dark:border-slate-700;
}
@mixin border-normal-right() {
@apply border-r border-slate-50 dark:border-slate-700;
}
@mixin border-normal-bottom() {
@apply border-b border-slate-50 dark:border-slate-700;
}
@mixin border-light() {
@apply border border-slate-25 dark:border-slate-700;
}
@mixin border-light-left() {
@apply border-l border-slate-25 dark:border-slate-700;
}
@mixin border-light-top() {
@apply border-t border-slate-25 dark:border-slate-700;
}
@mixin border-light-right() {
@apply border-r border-slate-25 dark:border-slate-700;
}
@mixin border-light-bottom() {
@apply border-b border-slate-25 dark:border-slate-700;
}
// background
@mixin background-gray() {
background: $color-background;
}
@mixin background-light() {
@apply bg-slate-50 dark:bg-slate-800;
}
@mixin background-white() {
@apply bg-white dark:bg-slate-900;
}
// input form // input form
@mixin ghost-input() { @mixin ghost-input() {
box-shadow: none; box-shadow: none;
@@ -87,65 +15,6 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
} }
} }
// flex-layout
@mixin space-between() {
display: flex;
justify-content: space-between;
}
@mixin space-between-column() {
@include space-between;
flex-direction: column;
}
@mixin space-between-row() {
@include space-between;
flex-direction: row;
}
@mixin flex-shrink() {
flex: 0 0 auto;
max-width: 100%;
}
@mixin flex-weight($value) {
// Grab flex-grow for older browsers.
$flex-grow: nth($value, 1);
// 2009
@include prefixer(box-flex, $flex-grow, webkit moz spec);
// 2011 (IE 10), 2012
@include prefixer(flex, $value, webkit moz ms spec);
}
// full height
@mixin full-height() {
height: 100%;
}
@mixin round-corner() {
border-radius: 1000px;
}
@mixin scroll-on-hover() {
overflow: hidden;
&:hover {
overflow-y: auto;
}
}
@mixin horizontal-scroll() {
overflow-y: auto;
}
@mixin elegant-card() {
@include normal-shadow;
border-radius: $space-small;
}
@mixin color-spinner() { @mixin color-spinner() {
@keyframes spinner { @keyframes spinner {
to { to {
@@ -230,17 +99,3 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
border-left: $size solid transparent; border-left: $size solid transparent;
} }
} }
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin three-column-grid($column-one-width: 16rem,
$column-three-width: 16rem) {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
}

View File

@@ -663,10 +663,10 @@ export default {
} }
&.is-failed { &.is-failed {
@apply bg-red-200 dark:bg-red-200; @apply bg-n-ruby-4 dark:bg-n-ruby-4 text-n-slate-12;
.message-text--metadata .time { .message-text--metadata .time {
@apply text-red-50 dark:text-red-50; @apply text-n-ruby-12 dark:text-n-ruby-12;
} }
} }
} }
@@ -727,7 +727,7 @@ li.right {
} }
.wrap.is-failed { .wrap.is-failed {
@apply flex items-end ml-auto; @apply flex items-end ltr:ml-auto rtl:mr-auto;
} }
} }

View File

@@ -3,10 +3,6 @@
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'widget/assets/scss/reset'; @import 'widget/assets/scss/reset';
@import 'widget/assets/scss/variables';
@import 'widget/assets/scss/buttons';
@import 'widget/assets/scss/mixins';
@import 'widget/assets/scss/forms';
@import 'shared/assets/fonts/InterDisplay/inter-display'; @import 'shared/assets/fonts/InterDisplay/inter-display';
html, html,
@@ -18,7 +14,6 @@ body {
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade // Taking these utils from tailwind 3.x.x, need to remove once we upgrade
.scroll-mt-24 { .scroll-mt-24 {
scroll-margin-top: 6rem; scroll-margin-top: 6rem;

View File

@@ -1,3 +1,19 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Thin.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Light.woff2') format('woff2');
}
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
@@ -6,6 +22,14 @@
src: url('shared/assets/fonts/Inter/Inter-Regular.woff2') format('woff2'); src: url('shared/assets/fonts/Inter/Inter-Regular.woff2') format('woff2');
} }
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Italic.woff2') format('woff2');
}
@font-face { @font-face {
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
@@ -13,3 +37,19 @@
font-display: swap; font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Medium.woff2') format('woff2'); src: url('shared/assets/fonts/Inter/Inter-Medium.woff2') format('woff2');
} }
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('shared/assets/fonts/Inter/Inter-Bold.woff2') format('woff2');
}

View File

@@ -53,10 +53,10 @@ export default {
:href="brandRedirectURL" :href="brandRedirectURL"
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
target="_blank" target="_blank"
class="branding--link justify-center items-center leading-3" class="branding--link text-n-slate-11 hover:text-n-slate-12 cursor-pointer text-xs inline-flex grayscale-[1] hover:grayscale-0 hover:opacity-100 opacity-90 no-underline justify-center items-center leading-3"
> >
<img <img
class="branding--image" class="ltr:mr-1 rtl:ml-1 max-w-3 max-h-3"
:alt="globalConfig.brandName" :alt="globalConfig.brandName"
:src="globalConfig.logoThumbnail" :src="globalConfig.logoThumbnail"
/> />
@@ -67,29 +67,3 @@ export default {
</div> </div>
<div v-else class="p-3" /> <div v-else class="p-3" />
</template> </template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.branding--image {
margin-right: $space-smaller;
max-width: $space-slab;
max-height: $space-slab;
}
.branding--link {
color: $color-light-gray;
cursor: pointer;
display: inline-flex;
filter: grayscale(1);
font-size: $font-size-small;
opacity: 0.9;
text-decoration: none;
&:hover {
filter: grayscale(0);
opacity: 1;
color: $color-gray;
}
}
</style>

View File

@@ -25,7 +25,7 @@ export default {
computed: { computed: {
buttonClassName() { buttonClassName() {
let className = let className =
'text-white py-3 px-4 rounded shadow-sm leading-4 cursor-pointer disabled:opacity-50'; 'text-white py-3 px-4 rounded-lg shadow-sm leading-4 cursor-pointer disabled:opacity-50';
if (this.type === 'clear') { if (this.type === 'clear') {
className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600'; className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600';
} }

View File

@@ -57,7 +57,7 @@ export default {
<button <button
v-else v-else
:key="action.payload" :key="action.payload"
class="action-button button" class="action-button button !bg-n-background dark:!bg-n-alpha-black1 text-n-brand"
:style="{ borderColor: widgetColor, color: widgetColor }" :style="{ borderColor: widgetColor, color: widgetColor }"
@click="onClick" @click="onClick"
> >
@@ -66,17 +66,7 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.action-button { .action-button {
align-items: center; @apply items-center rounded-lg flex font-medium justify-center mt-1 p-0 w-full;
border-radius: $space-micro;
display: flex;
font-weight: $font-weight-medium;
justify-content: center;
margin-top: $space-smaller;
max-height: 34px;
padding: 0;
width: 100%;
} }
</style> </style>

View File

@@ -1,6 +1,5 @@
<script> <script>
import CardButton from 'shared/components/CardButton.vue'; import CardButton from 'shared/components/CardButton.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
components: { components: {
@@ -24,71 +23,27 @@ export default {
default: () => [], default: () => [],
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
}; };
</script> </script>
<template> <template>
<div <div
class="card-message chat-bubble agent" class="card-message chat-bubble agent bg-n-background dark:bg-n-solid-3 max-w-56 rounded-lg overflow-hidden"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
> >
<img class="media" :src="mediaUrl" /> <img
class="w-full object-contain max-h-[150px] rounded-[5px]"
:src="mediaUrl"
/>
<div class="card-body"> <div class="card-body">
<h4 <h4
class="title" class="!text-base !font-medium !mt-1 !mb-1 !leading-[1.5] text-n-slate-12"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
> >
{{ title }} {{ title }}
</h4> </h4>
<p <p class="!mb-1 text-n-slate-11">
class="body"
:class="getThemeClass('text-black-700', 'dark:text-slate-100')"
>
{{ description }} {{ description }}
</p> </p>
<CardButton v-for="action in actions" :key="action.id" :action="action" /> <CardButton v-for="action in actions" :key="action.id" :action="action" />
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
@import 'dashboard/assets/scss/mixins.scss';
.card-message {
max-width: 220px;
padding: $space-small;
border-radius: $space-small;
overflow: hidden;
.title {
font-size: $font-size-default;
font-weight: $font-weight-medium;
margin-top: $space-smaller;
margin-bottom: $space-smaller;
line-height: 1.5;
}
.body {
margin-bottom: $space-smaller;
}
.media {
@include border-light;
width: 100%;
object-fit: contain;
max-height: 150px;
border-radius: 5px;
}
.action-button + .action-button {
background: $color-white;
@include thin-border($color-woot);
color: $color-woot;
}
}
</style>

View File

@@ -1,7 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
props: { props: {
@@ -19,10 +18,6 @@ export default {
}, },
}, },
emits: ['submit'], emits: ['submit'],
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() { data() {
return { return {
formValues: {}, formValues: {},
@@ -36,10 +31,6 @@ export default {
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
},
isFormValid() { isFormValid() {
return this.items.reduce((acc, { name }) => { return this.items.reduce((acc, { name }) => {
return !!this.formValues[name] && acc; return !!this.formValues[name] && acc;
@@ -83,25 +74,23 @@ export default {
<template> <template>
<div <div
class="form chat-bubble agent" class="form chat-bubble agent w-full p-4 bg-n-background dark:bg-n-solid-3"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
> >
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div <div
v-for="item in items" v-for="item in items"
:key="item.key" :key="item.key"
class="form-block" class="pb-2 w-full"
:class="{ :class="{
'has-submitted': hasSubmitted, 'has-submitted': hasSubmitted,
}" }"
> >
<label :class="getThemeClass('text-black-900', 'dark:text-slate-50')">{{ <label class="text-n-slate-12">
item.label {{ item.label }}
}}</label> </label>
<input <input
v-if="item.type === 'email'" v-if="item.type === 'email'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:type="item.type" :type="item.type"
:pattern="item.regex" :pattern="item.regex"
:title="item.title" :title="item.title"
@@ -113,7 +102,6 @@ export default {
<input <input
v-else-if="item.type === 'text'" v-else-if="item.type === 'text'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
:pattern="item.pattern" :pattern="item.pattern"
:title="item.title" :title="item.title"
@@ -125,7 +113,6 @@ export default {
<textarea <textarea
v-else-if="item.type === 'text_area'" v-else-if="item.type === 'text_area'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
:title="item.title" :title="item.title"
:name="item.name" :name="item.name"
@@ -135,7 +122,6 @@ export default {
<select <select
v-else-if="item.type === 'select'" v-else-if="item.type === 'select'"
v-model="formValues[item.name]" v-model="formValues[item.name]"
:class="inputColor"
:required="item.required && 'required'" :required="item.required && 'required'"
> >
<option <option
@@ -168,87 +154,31 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.form { .form {
padding: $space-normal;
width: 80%;
.form-block {
width: 90%;
padding-bottom: $space-small;
}
label { label {
display: block; @apply block font-medium py-1 px-0 capitalize;
font-weight: $font-weight-medium;
padding: $space-smaller 0;
text-transform: capitalize;
}
input,
textarea {
border-radius: $space-smaller;
border: 1px solid $color-border;
display: block;
font-family: inherit;
font-size: $font-size-default;
line-height: 1.5;
padding: $space-one;
width: 100%;
&:disabled {
background: $color-background-light;
}
}
textarea {
resize: none;
}
select {
width: 110%;
padding: $space-smaller;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 1px solid $color-border;
border-radius: $space-smaller;
font-family: inherit;
font-size: $space-normal;
font-weight: normal;
line-height: 1.5;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-origin: content-box;
background-position: right -1.6rem center;
background-repeat: no-repeat;
background-size: 9px 6px;
padding-right: 2.4rem;
} }
.button { .button {
font-size: $font-size-default; @apply text-sm rounded-lg;
} }
.error-message { .error-message {
display: none; @apply text-n-ruby-9 mt-1 hidden;
margin-top: $space-smaller; }
color: $color-error;
input,
textarea,
select {
@apply dark:bg-n-alpha-black1;
} }
.has-submitted { .has-submitted {
input:invalid { input:invalid,
border: 1px solid $color-error;
}
input:invalid + .error-message {
display: block;
}
textarea:invalid { textarea:invalid {
border: 1px solid $color-error; @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
} }
input:invalid + .error-message,
textarea:invalid + .error-message { textarea:invalid + .error-message {
display: block; display: block;
} }

View File

@@ -40,35 +40,16 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.option { .option {
border-radius: $space-jumbo; @apply rounded-[5rem] border border-solid border-n-brand ltr:float-left rtl:float-right m-1 max-w-full;
border: 1px solid $color-woot;
float: left;
margin: $space-smaller;
max-width: 100%;
.option-button { .option-button {
background: transparent; @apply bg-transparent border-0 cursor-pointer h-auto leading-normal ltr:text-left rtl:text-right whitespace-normal rounded-[2rem] min-h-[2.5rem];
border-radius: $space-large;
border: 0;
cursor: pointer;
height: auto;
line-height: 1.5;
min-height: $space-two * 2;
text-align: left;
white-space: normal;
span { span {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }
> .icon {
margin-right: $space-smaller;
font-size: $font-size-medium;
}
} }
} }
</style> </style>

View File

@@ -43,66 +43,24 @@ export default {
</script> </script>
<template> <template>
<div class="options-message chat-bubble agent bg-white dark:bg-slate-700"> <div
<div class="card-body"> class="chat-bubble agent max-w-64 !py-2 !px-4 rounded-lg overflow-hidden mt-1 bg-n-background dark:bg-n-solid-3"
<h4 class="title text-black-900 dark:text-slate-50"> >
<div <h4 class="text-n-slate-12 text-sm font-normal my-1 leading-[1.5]">
v-dompurify-html="formatMessage(title, false)" <div
class="message-content text-black-900 dark:text-slate-50" v-dompurify-html="formatMessage(title, false)"
/> class="text-n-slate-12"
</h4> />
<ul </h4>
v-if="!hideFields" <ul v-if="!hideFields" class="w-full">
class="options" <ChatOption
:class="{ 'has-selected': !!selected }" v-for="option in options"
> :key="option.id"
<ChatOption :action="option"
v-for="option in options" :is-selected="isSelected(option)"
:key="option.id" class="list-none p-0"
:action="option" @option-select="onClick"
:is-selected="isSelected(option)" />
@option-select="onClick" </ul>
/>
</ul>
</div>
</div> </div>
</template> </template>
<style lang="scss">
@import 'dashboard/assets/scss/variables.scss';
.has-selected {
.option-button:not(.is-selected) {
color: $color-light-gray;
cursor: initial;
}
}
</style>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.options-message {
max-width: 17rem;
padding: $space-small $space-normal;
border-radius: $space-small;
overflow: hidden;
.title {
font-size: $font-size-default;
font-weight: $font-weight-normal;
margin-top: $space-smaller;
margin-bottom: $space-smaller;
line-height: 1.5;
}
.options {
width: 100%;
> li {
list-style: none;
padding: 0;
}
}
}
</style>

View File

@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS } from 'shared/constants/messages'; import { CSAT_RATINGS } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
export default { export default {
@@ -21,10 +20,6 @@ export default {
required: true, required: true,
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() { data() {
return { return {
email: '', email: '',
@@ -46,10 +41,6 @@ export default {
isButtonDisabled() { isButtonDisabled() {
return !(this.selectedRating && this.feedback); return !(this.selectedRating && this.feedback);
}, },
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
},
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },
@@ -107,17 +98,13 @@ export default {
<template> <template>
<div <div
class="customer-satisfaction" class="customer-satisfaction w-full bg-n-background dark:bg-n-solid-3 shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg inline-block leading-[1.5] mt-1 border-t-2 border-t-n-brand border-solid"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:style="{ borderColor: widgetColor }" :style="{ borderColor: widgetColor }"
> >
<h6 <h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
class="title"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
>
{{ title }} {{ title }}
</h6> </h6>
<div class="ratings"> <div class="ratings flex justify-around py-5 px-4">
<button <button
v-for="rating in ratings" v-for="rating in ratings"
:key="rating.key" :key="rating.key"
@@ -129,13 +116,11 @@ export default {
</div> </div>
<form <form
v-if="!isFeedbackSubmitted" v-if="!isFeedbackSubmitted"
class="feedback-form" class="feedback-form flex"
@submit.prevent="onSubmit()" @submit.prevent="onSubmit()"
> >
<input <input
v-model="feedback" v-model="feedback"
class="form-input"
:class="inputColor"
:placeholder="$t('CSAT.PLACEHOLDER')" :placeholder="$t('CSAT.PLACEHOLDER')"
@keydown.enter="onSubmit" @keydown.enter="onSubmit"
/> />
@@ -156,80 +141,35 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
@import 'widget/assets/scss/mixins.scss';
.customer-satisfaction { .customer-satisfaction {
@include light-shadow;
border-bottom-left-radius: $space-smaller;
border-radius: $space-small;
border-top: $space-micro solid $color-woot;
color: $color-body;
display: inline-block;
line-height: 1.5;
margin-top: $space-smaller;
width: 80%;
.title {
font-size: $font-size-default;
font-weight: $font-weight-medium;
padding: $space-two $space-one 0;
text-align: center;
}
.ratings { .ratings {
display: flex;
justify-content: space-around;
padding: $space-two $space-normal;
.emoji-button { .emoji-button {
box-shadow: none; @apply shadow-none grayscale text-2xl outline-none transition-all duration-200;
filter: grayscale(100%);
font-size: $font-size-big;
outline: none;
&.selected, &.selected,
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
filter: grayscale(0%); @apply grayscale-0 scale-[1.32];
transform: scale(1.32);
} }
&.disabled { &.disabled {
cursor: default; @apply cursor-not-allowed opacity-50 pointer-events-none;
opacity: 0.5;
pointer-events: none;
} }
} }
} }
.feedback-form { .feedback-form {
display: flex;
input { input {
border-bottom-right-radius: 0; @apply h-10 dark:bg-n-alpha-black1 rtl:rounded-tl-[0] rtl:rounded-tr-[0] ltr:rounded-tr-[0] ltr:rounded-tl-[0] rtl:rounded-bl-[0] ltr:rounded-br-[0] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg p-2.5 w-full focus:ring-0 focus:outline-n-brand;
border-top-right-radius: 0;
border-bottom-left-radius: $space-small;
border: 0;
border-top: 1px solid $color-border;
padding: $space-one;
width: 100%;
&::placeholder { &::placeholder {
color: $color-light-gray; @apply text-n-slate-10;
} }
} }
.button { .button {
appearance: none; @apply rtl:rounded-tr-[0] rtl:rounded-tl-[0] appearance-none ltr:rounded-tl-[0] ltr:rounded-tr-[0] rtl:rounded-br-[0] ltr:rounded-bl-[0] rounded-lg h-auto ltr:-ml-px rtl:-mr-px text-xl;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
border-bottom-right-radius: $space-small;
font-size: $font-size-large;
height: auto;
margin-left: -1px;
.spinner { .spinner {
display: block; display: block;
@@ -240,10 +180,4 @@ export default {
} }
} }
} }
@media (prefers-color-scheme: dark) {
.customer-satisfaction .feedback-form input {
border-top: 1px solid var(--b-500);
}
}
</style> </style>

View File

@@ -1,6 +1,5 @@
<script> <script>
import { formatDate } from 'shared/helpers/DateHelper'; import { formatDate } from 'shared/helpers/DateHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
props: { props: {
@@ -9,10 +8,6 @@ export default {
required: true, required: true,
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: { computed: {
formattedDate() { formattedDate() {
return formatDate({ return formatDate({
@@ -27,40 +22,8 @@ export default {
<template> <template>
<div <div
class="date--separator" class="text-sm text-n-slate-11 h-[50px] leading-[50px] relative text-center w-full before:content-[''] before:h-px before:absolute before:top-6 before:w-[calc((100%-120px)/2)] before:bg-n-slate-4 before:dark:bg-n-slate-6 before:left-0 after:content-[''] after:h-px after:absolute after:top-6 after:w-[calc((100%-120px)/2)] after:bg-n-slate-4 after:dark:bg-n-slate-6 after:right-0"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
> >
{{ formattedDate }} {{ formattedDate }}
</div> </div>
</template> </template>
<style lang="scss" scoped>
@import 'widget/assets/scss/variables';
.date--separator {
font-size: $font-size-default;
height: 50px;
line-height: 50px;
position: relative;
text-align: center;
width: 100%;
}
.date--separator::before,
.date--separator::after {
background-color: $color-border;
content: '';
height: 1px;
position: absolute;
top: 24px;
width: calc((100% - 120px) / 2);
}
.date--separator::before {
left: 0;
}
.date--separator::after {
right: 0;
}
</style>

View File

@@ -11,6 +11,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
isRtl: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@@ -18,6 +22,32 @@ export default {
showEmptyState: !this.url, showEmptyState: !this.url,
}; };
}, },
watch: {
isRtl: {
immediate: true,
handler(value) {
this.$nextTick(() => {
const iframeElement = this.$el.querySelector('iframe');
if (iframeElement) {
iframeElement.onload = () => {
try {
const iframeDocument =
iframeElement.contentDocument ||
(iframeElement.contentWindow &&
iframeElement.contentWindow.document);
if (iframeDocument) {
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
}
} catch (e) {
// error
}
};
}
});
},
},
},
methods: { methods: {
handleIframeLoad() { handleIframeLoad() {
// Once loaded, the loading state is hidden // Once loaded, the loading state is hidden

View File

@@ -35,82 +35,41 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss'; @keyframes spinner {
to {
@mixin color-spinner() { transform: rotate(360deg);
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&:before {
content: '';
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: $space-medium;
height: $space-medium;
margin-top: -$space-one;
margin-left: -$space-one;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.8);
border-top-color: rgba(255, 255, 255, 0.3);
animation: spinner 0.9s linear infinite;
} }
} }
.spinner { .spinner {
@include color-spinner(); @apply relative inline-block w-6 h-6 align-middle;
position: relative;
display: inline-block; &:before {
width: $space-medium; @apply border-n-slate-10 border-2 border-solid content-[''] box-border absolute top-[50%] left-[50%] rounded-full border-t-n-strong -ml-2.5 -mt-2.5 w-6 h-6 animate-[spinner_0.9s_linear_infinite];
height: $space-medium; }
padding: $zero $space-medium;
vertical-align: middle;
&.message { &.message {
padding: $space-one; @apply p-2.5 top-0 left-0 mx-auto my-0 mt-3 bg-n-background rounded-[2rem];
top: 0;
left: 0;
margin: 0 auto;
margin-top: $space-slab;
background: $color-white;
border-radius: $space-large;
&:before { &:before {
margin-top: -$space-slab; @apply -mt-3 -ml-3;
margin-left: -$space-slab;
} }
} }
&.small { &.small {
width: $space-normal; @apply w-4 h-4;
height: $space-normal;
&:before { &:before {
width: $space-normal; @apply w-4 h-4 -mt-2;
height: $space-normal;
margin-top: -$space-small;
} }
} }
&.tiny { &.tiny {
width: $space-one; @apply w-2.5 h-2.5 py-0 px-1;
height: $space-one;
padding: 0 $space-smaller;
&:before { &:before {
width: $space-one; @apply w-2.5 h-2.5 -mt-1.5;
height: $space-one;
margin-top: -$space-small + $space-micro;
} }
} }
&.dark::before {
border-color: rgba(0, 0, 0, 0.7);
border-top-color: rgba(0, 0, 0, 0.2);
}
} }
</style> </style>

View File

@@ -2,18 +2,8 @@
exports[`DateSeparator > date separator snapshot 1`] = ` exports[`DateSeparator > date separator snapshot 1`] = `
<div <div
class="date--separator text-slate-700" class="text-sm text-n-slate-11 h-[50px] leading-[50px] relative text-center w-full before:content-[''] before:h-px before:absolute before:top-6 before:w-[calc((100%-120px)/2)] before:bg-n-slate-4 before:dark:bg-n-slate-6 before:left-0 after:content-[''] after:h-px after:absolute after:top-6 after:w-[calc((100%-120px)/2)] after:bg-n-slate-4 after:dark:bg-n-slate-6 after:right-0"
data-v-b24b73fa=""
> >
Nov 18, 2019 Nov 18, 2019
</div> </div>
`; `;
exports[`dateSeparator > date separator snapshot 1`] = `
<div
class="date--separator text-slate-700"
data-v-b24b73fa=""
>
Nov 18, 2019
</div>
`;

View File

@@ -2,15 +2,11 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'widget/assets/scss/reset'; @import 'widget/assets/scss/reset';
@import 'widget/assets/scss/variables';
@import 'widget/assets/scss/buttons';
@import 'widget/assets/scss/mixins';
@import 'widget/assets/scss/forms';
@import 'shared/assets/fonts/widget_fonts'; @import 'shared/assets/fonts/widget_fonts';
html, html,
body { body {
font-family: $font-family; font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Tahoma, Arial, sans-serif;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
height: 100%; height: 100%;

View File

@@ -180,9 +180,7 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.logo { .logo {
max-height: $space-larger; max-height: 3rem;
} }
</style> </style>

View File

@@ -6,6 +6,7 @@ import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import configMixin from './mixins/configMixin'; import configMixin from './mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability'; import availabilityMixin from 'widget/mixins/availability';
import { getLocale } from './helpers/urlParamsHelper'; import { getLocale } from './helpers/urlParamsHelper';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import { isEmptyObject } from 'widget/helpers/utils'; import { isEmptyObject } from 'widget/helpers/utils';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import routerMixin from './mixins/routerMixin'; import routerMixin from './mixins/routerMixin';
@@ -57,11 +58,22 @@ export default {
isRNWebView() { isRNWebView() {
return RNHelper.isRNWebView(); return RNHelper.isRNWebView();
}, },
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
}, },
watch: { watch: {
activeCampaign() { activeCampaign() {
this.setCampaignView(); this.setCampaignView();
}, },
isRTL: {
immediate: true,
handler(value) {
document.documentElement.dir = value ? 'rtl' : 'ltr';
},
},
}, },
mounted() { mounted() {
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel; const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
@@ -335,7 +347,7 @@ export default {
<template> <template>
<div <div
v-if="!conversationSize && isFetchingList" v-if="!conversationSize && isFetchingList"
class="flex items-center justify-center flex-1 h-full bg-black-25" class="flex items-center justify-center flex-1 h-full bg-n-background"
:class="{ dark: prefersDarkMode }" :class="{ dark: prefersDarkMode }"
> >
<Spinner size="" /> <Spinner size="" />

View File

@@ -1,71 +0,0 @@
$button-border-width: 1px;
// Buttons
.button {
appearance: none;
background: $color-primary;
border: $button-border-width solid $color-primary;
border-radius: $border-radius;
color: $color-white;
cursor: pointer;
display: inline-block;
font-size: $font-size-default;
height: $space-two * 2;
line-height: $line-height;
outline: none;
padding: $space-smaller $space-normal;
text-align: center;
text-decoration: none;
transition: background .2s, border .2s, box-shadow .2s, color .2s;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:focus,
&:hover {
background: lighten($color-primary, 7%);
border-color: $color-primary;
text-decoration: none;
}
&:active,
&.active {
background: $color-primary;
border-color: darken($color-primary, 5%);
color: lighten($color-primary, 20%);
text-decoration: none;
}
&[disabled],
&:disabled,
&.disabled {
cursor: default;
opacity: .5;
pointer-events: none;
}
&.small {
font-size: $font-size-small;
height: $space-medium;
padding: $space-smaller $space-slab;
}
&.large {
font-size: $font-size-medium;
height: $space-larger;
padding: $space-small $space-medium;
}
&.block {
width: 100%;
}
&.transparent {
background: transparent;
border: 0;
height: auto;
}
&.compact {
padding: 0;
}
}

View File

@@ -1,83 +0,0 @@
// scss-lint:disable PropertySortOrder DeclarationOrder QualifyingElement
$form-border-width: 1px;
$input-height: $space-two * 2;
.form-input {
@include placeholder {
color: $color-gray;
}
appearance: none;
background: $color-white;
border: 1px solid $color-border;
border-radius: $border-radius;
box-sizing: border-box;
color: $color-body;
display: block;
font-family: $font-family;
font-size: $font-size-medium;
height: $input-height;
line-height: 1.5;
max-width: 100%;
outline: none;
padding: $space-smaller;
position: relative;
transition: background .2s,
border .2s,
box-shadow .2s,
color .2s;
width: 100%;
&:focus {
border-color: $color-primary;
}
&::placeholder {
color: $color-gray;
}
// Input sizes
&.small {
font-size: $font-size-small;
height: $space-large;
padding: $space-small $space-one;
}
&.default {
font-size: $font-size-default;
height: $space-medium;
padding: $space-smaller $space-slab;
}
&.large {
font-size: $font-size-medium;
height: $space-larger;
padding: $space-slab $space-two;
}
&.input-inline {
display: inline-block;
vertical-align: middle;
width: auto;
}
// Input types
&[type="file"] {
height: auto;
}
}
// Form element: Textarea
textarea.form-input {
font-family: $font-family;
@include placeholder {
color: $color-light-gray;
}
&,
&.large,
&.small {
height: auto;
}
}

View File

@@ -1,78 +0,0 @@
// scss-lint:disable PseudoElement SpaceBeforeBrace VendorPrefix
$shadow-color-1: rgba(50, 50, 93, 0.08);
$shadow-color-2: rgba(0, 0, 0, 0.07);
$shadow-color-3: rgba(50, 50, 93, 0.08);
$shadow-color-4: rgba(0, 0, 0, 0.05);
$color-shadow-medium: rgba(50, 50, 93, 0.08);
$color-shadow-light: rgba(50, 50, 93, 0.04);
$color-shadow-large: rgba(50, 50, 93, 0.25);
$color-shadow-outline: rgba(66, 153, 225, 0.5);
@mixin normal-shadow {
box-shadow: 0 $space-small $space-normal $shadow-color-1,
0 $space-smaller $space-slab $shadow-color-2;
}
@mixin light-shadow {
box-shadow: 0 $space-smaller 6px $shadow-color-3, 0 1px 3px $shadow-color-4;
}
@mixin placeholder {
&::-webkit-input-placeholder {
@content;
}
&:-moz-placeholder {
@content;
}
&::-moz-placeholder {
@content;
}
&:-ms-input-placeholder {
@content;
}
}
@mixin shadow {
box-shadow: 0 1px 10px 2px $color-shadow-medium,
0 1px 5px 1px $color-shadow-light;
}
@mixin shadow-medium {
box-shadow: 0 4px 24px 4px $color-shadow-medium,
0 2px 16px 2px $color-shadow-light;
}
@mixin shadow-large {
box-shadow: 0 10px 15px -16px $color-shadow-medium,
0 4px 6px -8px $color-shadow-light;
}
@mixin shadow-big {
box-shadow: 0 20px 25px -20px $color-shadow-medium,
0 10px 10px -10px $color-shadow-light;
}
@mixin shadow-mega {
box-shadow: 0 25px 50px -12px $color-shadow-big;
}
@mixin shadow-inner {
box-shadow: inset 0 2px 4px 0 $color-shadow-light;
}
@mixin shadow-outline {
box-shadow: 0 0 0 3px $color-shadow-outline;
}
@mixin shadow-none {
box-shadow: none;
}
@mixin button-size {
min-height: $space-large;
min-width: $space-large;
}

View File

@@ -1,3 +0,0 @@
.icon-button {
@include button-size;
}

View File

@@ -1,87 +0,0 @@
// Font sizes
$font-size-micro: 0.5rem;
$font-size-mini: 0.625rem;
$font-size-small: 0.75rem;
$font-size-default: 0.875rem;
$font-size-medium: 1rem;
$font-size-large: 1.25rem;
$font-size-big: 1.5rem;
$font-size-bigger: 2rem;
$font-size-mega: 2.5rem;
$font-size-giga: 3.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.625rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-big: 4rem;
$space-jumbo: 5rem;
$space-mega: 6.25rem;
$border-radius-small: 0.1875rem;
$border-radius-normal: 0.3125rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
// Colors
$color-woot: #1f93ff;
$color-primary: $color-woot;
$color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-transparent: rgba(224, 230, 237, 0.5);
$color-border-light: #f0f4f5;
$color-border-dark: #cad0d4;
$color-background: #f4f6fb;
$color-background-light: #fafafa;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-error: #ff382d;
$color-success: #44ce4b;
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: 0.4s !default;
$swift-ease-out-timing-function: cubic-bezier(0.25, 0.8, 0.25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
$border-radius: 0.1875px;
$line-height: 1;
$footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10;
$font-family: 'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif;
// Break points
$break-point-medium: 667px;

View File

@@ -4,28 +4,18 @@
.conversation-wrap { .conversation-wrap {
.agent-message { .agent-message {
align-items: flex-end; @apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[88%];
display: flex;
flex-direction: row;
justify-content: flex-start;
margin: 0 0 $space-micro $space-small;
max-width: 88%;
.avatar-wrap { .avatar-wrap {
flex-shrink: 0; @apply flex-shrink-0 h-6 w-6;
height: $space-medium;
width: $space-medium;
.user-thumbnail-box { .user-thumbnail-box {
margin-top: -$space-large; @apply -mt-8;
} }
} }
.message-wrap { .message-wrap {
flex-grow: 1; @apply flex-grow flex-shrink-0 ltr:ml-2 rtl:mr-2 max-w-[90%];
flex-shrink: 0;
margin-left: $space-small;
max-width: 90%;
} }
} }
@@ -42,10 +32,7 @@
} }
.agent-name { .agent-name {
font-size: $font-size-small; @apply text-xs font-medium my-2 ltr:pl-0.5 rtl:pr-0.5;
font-weight: $font-weight-medium;
margin: $space-small 0;
padding-left: $space-micro;
} }
.has-attachment { .has-attachment {
@@ -56,146 +43,127 @@
} }
&.has-text { &.has-text {
margin-top: $space-smaller; @apply mt-1;
} }
} }
.agent-message-wrap { .agent-message-wrap {
+ .agent-message-wrap { + .agent-message-wrap {
margin-top: $space-micro; @apply mt-0.5;
.agent-message .chat-bubble { .agent-message .chat-bubble {
border-top-left-radius: $space-smaller; @apply ltr:rounded-tl-[0.25rem] rtl:rounded-tr-[0.25rem];
} }
} }
+ .user-message-wrap { + .user-message-wrap {
margin-top: $space-normal; @apply mt-4;
} }
&.has-response + .user-message-wrap { &.has-response + .user-message-wrap {
margin-top: $space-micro; @apply mt-0.5;
.chat-bubble { .chat-bubble {
border-top-right-radius: $space-smaller; @apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
} }
} }
&.has-response + .agent-message-wrap { &.has-response + .agent-message-wrap {
margin-top: $space-normal; @apply mt-4;
} }
} }
.user-message { .user-message {
align-items: flex-end; @apply flex items-end flex-row justify-end max-w-[85%] ltr:text-right rtl:text-left mt-0 ltr:ml-auto rtl:mr-auto ltr:mr-1 rtl:ml-1 mb-0.5;
display: flex;
flex-direction: row;
justify-content: flex-end;
margin: 0 $space-smaller $space-micro auto;
max-width: 85%;
text-align: right;
.message-wrap { .message-wrap {
margin-right: $space-small; @apply max-w-full ltr:mr-2 rtl:ml-2;
max-width: 100%;
} }
.in-progress, .in-progress {
.is-failed {
opacity: 0.6; opacity: 0.6;
} }
.is-failed { .is-failed {
align-items: flex-end; @apply flex items-end flex-row-reverse;
display: flex;
flex-direction: row-reverse;
.chat-bubble.user { .chat-bubble.user {
background: $color-error !important; @apply bg-n-ruby-9 dark:bg-n-ruby-9 #{!important};
// TODO: Remove the important
} }
} }
} }
.user.has-attachment { .user.has-attachment {
.icon-wrap { .icon-wrap {
color: $color-white; @apply text-white;
} }
.download { .download {
color: $color-white; @apply text-white;
} }
} }
.user-message-wrap { .user-message-wrap {
+ .user-message-wrap { + .user-message-wrap {
margin-top: $space-micro; @apply mt-0.5;
.user-message .chat-bubble { .user-message .chat-bubble {
border-top-right-radius: $space-smaller; @apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
} }
} }
+ .agent-message-wrap { + .agent-message-wrap {
margin-top: $space-normal; @apply mt-4;
} }
} }
p:not(:last-child) { p:not(:last-child) {
margin-bottom: $space-normal; @apply mb-4;
} }
} }
.unread-messages { .unread-messages {
display: flex; @apply flex flex-col flex-nowrap mt-0 overflow-y-auto w-full pb-2;
flex-direction: column;
flex-wrap: nowrap;
margin-top: 0;
overflow-y: auto;
padding-bottom: $space-small;
width: 100%;
.chat-bubble-wrap { .chat-bubble-wrap {
margin-bottom: $space-smaller; @apply mb-1;
&:first-child { &:first-child {
margin-top: auto; margin-top: auto;
} }
.chat-bubble { .chat-bubble {
border: 1px solid $color-border-dark; @apply border border-solid border-n-slate-5 dark:border-n-slate-11/50 text-n-black;
} }
+ .chat-bubble-wrap { + .chat-bubble-wrap {
.chat-bubble { .chat-bubble {
border-top-left-radius: $space-smaller; @apply ltr:rounded-tl-[0.25rem] rtl:rounded-tr-[0.25rem];
} }
} }
&:last-child .chat-bubble { &:last-child .chat-bubble {
border-bottom-left-radius: $space-two; @apply ltr:rounded-bl-[1.25rem] rtl:rounded-br-[1.25rem];
} }
} }
} }
.is-widget-right .unread-wrap { .is-widget-right .unread-wrap {
overflow: hidden; @apply ltr:text-right rtl:text-left overflow-hidden;
text-align: right;
.chat-bubble-wrap { .chat-bubble-wrap {
.chat-bubble { .chat-bubble {
border-bottom-right-radius: $space-smaller; @apply ltr:rounded-br-[0.25rem] rtl:rounded-bl-[0.25rem] rounded-[1.25rem];
border-radius: $space-two;
} }
+ .chat-bubble-wrap { + .chat-bubble-wrap {
.chat-bubble { .chat-bubble {
border-top-right-radius: $space-smaller; @apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
} }
} }
&:last-child .chat-bubble { &:last-child .chat-bubble {
border-bottom-right-radius: $space-two; @apply ltr:rounded-br-[1.25rem] rtl:rounded-bl-[1.25rem];
} }
} }
@@ -205,15 +173,8 @@
} }
.chat-bubble { .chat-bubble {
@include light-shadow; @apply shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] rounded-[1.25rem] inline-block text-sm leading-[1.5] max-w-full ltr:text-left rtl:text-right py-3 px-4 text-white;
border-radius: $space-two;
color: $color-white;
display: inline-block;
font-size: $font-size-default;
line-height: 1.5;
max-width: 100%;
padding: $space-slab $space-normal;
text-align: left;
word-break: break-word; word-break: break-word;
:not([audio]) { :not([audio]) {
@@ -221,7 +182,7 @@
} }
> a { > a {
color: $color-primary; @apply text-n-brand;
word-break: break-all; word-break: break-all;
} }
@@ -230,19 +191,18 @@
} }
&.user { &.user {
border-bottom-right-radius: $space-smaller; @apply ltr:rounded-br-[0.25rem] rtl:rounded-bl-[0.25rem];
> a { > a {
color: $color-white; @apply text-white;
} }
} }
&.agent { &.agent {
border-bottom-left-radius: $space-smaller; @apply ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] text-n-slate-12;
color: $color-body;
.link { .link {
color: $color-woot; @apply text-n-brand;
word-break: break-word; word-break: break-word;
} }
} }

View File

@@ -5,20 +5,12 @@
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'variables';
@import 'buttons';
@import 'mixins';
@import 'forms';
@import 'utilities';
@import 'shared/assets/fonts/widget_fonts'; @import 'shared/assets/fonts/widget_fonts';
@import 'views/conversation'; @import 'views/conversation';
html, html,
body { body {
font-family: $font-family; @apply antialiased h-full bg-n-background;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
} }
.is-mobile { .is-mobile {
@@ -43,19 +35,15 @@ body {
} }
} }
.cursor-pointer {
cursor: pointer;
}
.message-content { .message-content {
ul { ul {
list-style: disc; list-style: disc;
padding-left: $space-slab; @apply ltr:pl-3 rtl:pr-3;
} }
ol { ol {
list-style: decimal; list-style: decimal;
padding-left: $space-normal; @apply ltr:pl-4 rtl:pr-4;
} }
} }
@@ -82,3 +70,316 @@ body {
} }
} }
} }
label {
@apply block font-medium py-1 px-0 capitalize;
}
input:not(.reset-base),
textarea:not(.reset-base) {
font-family: inherit;
@apply rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] outline-n-weak block text-base leading-[1.5] p-2.5 w-full text-n-slate-12 focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
&:disabled {
@apply opacity-40 cursor-not-allowed;
}
&:placeholder-shown {
@apply text-ellipsis;
}
}
textarea {
resize: none;
}
select:not(.reset-base) {
@apply bg-n-background dark:bg-n-alpha-2 w-full p-2.5 border-none outline outline-1 outline-offset-[-1px] outline-n-weak rounded-lg text-n-slate-12 text-base ltr:pr-10 rtl:pl-10 font-normal ltr:bg-[right_-1.6rem_center] rtl:bg-[left_-1.6rem_center] focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-origin: content-box;
background-repeat: no-repeat;
background-size: 9px 6px;
font-family: inherit;
}
p code {
@apply bg-n-slate-3 dark:bg-n-alpha-2 text-n-slate-11 text-sm inline-block rounded py-px px-1;
}
pre {
@apply bg-n-slate-3 dark:bg-n-alpha-2 text-n-slate-11 overflow-y-auto rounded-md p-2 mt-1 mb-2 block leading-[1.5] whitespace-pre-wrap;
code {
@apply bg-transparent text-n-slate-11 p-0 text-sm;
}
}
blockquote {
@apply ltr:border-l-4 rtl:border-r-4 border-n-slate-3 dark:border-n-alpha-2 border-solid my-1 px-0 text-n-slate-11 py-1 ltr:pr-2 rtl:pr-4 ltr:pl-4 rtl:pl-2;
}
.button {
@apply appearance-none bg-n-brand border border-solid border-n-brand text-white cursor-pointer inline-block text-sm h-10 leading-none outline-none outline-0 py-1 px-4 text-center no-underline select-none align-middle whitespace-nowrap;
&:focus,
&:hover {
@apply no-underline border-n-brand brightness-110;
}
&:active,
&.active {
@apply no-underline border-n-brand brightness-125;
}
&[disabled],
&:disabled,
&.disabled {
cursor: default;
opacity: 0.5;
pointer-events: none;
}
&.small {
@apply text-xs h-6 py-1 px-3;
}
&.large {
@apply text-base h-12 py-2 px-6;
}
&.block {
width: 100%;
}
&.transparent {
background: transparent;
border: 0;
height: auto;
}
&.compact {
padding: 0;
}
}
// scss-lint:disable PropertySortOrder
@layer base {
// NEXT COLORS START
:root {
// slate
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
// iris
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
// ruby
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
// amber
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
// teal
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
// gray
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
// slate
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
// iris
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
// ruby
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
// amber
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
// teal
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
// gray
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}

View File

@@ -11,7 +11,6 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin'; import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin'; import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper'; import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
import ReplyToChip from 'widget/components/ReplyToChip.vue'; import ReplyToChip from 'widget/components/ReplyToChip.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -39,12 +38,6 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return {
getThemeClass,
};
},
data() { data() {
return { return {
hasImageError: false, hasImageError: false,
@@ -183,8 +176,15 @@ export default {
<div v-if="hasReplyTo" class="flex mt-2 mb-1 text-xs"> <div v-if="hasReplyTo" class="flex mt-2 mb-1 text-xs">
<ReplyToChip :reply-to="replyTo" /> <ReplyToChip :reply-to="replyTo" />
</div> </div>
<div class="flex gap-1"> <div class="flex w-full gap-1">
<div class="space-y-2"> <div
class="space-y-2"
:class="{
'w-full':
contentType === 'form' &&
!messageContentAttributes?.submitted_values,
}"
>
<AgentMessageBubble <AgentMessageBubble
v-if="shouldDisplayAgentMessage" v-if="shouldDisplayAgentMessage"
:content-type="contentType" :content-type="contentType"
@@ -195,10 +195,8 @@ export default {
/> />
<div <div
v-if="hasAttachments" v-if="hasAttachments"
class="space-y-2 chat-bubble has-attachment agent" class="space-y-2 chat-bubble has-attachment agent bg-n-background dark:bg-n-solid-3"
:class=" :class="wrapClass"
(wrapClass, getThemeClass('bg-white', 'dark:bg-slate-700'))
"
> >
<div <div
v-for="attachment in message.attachments" v-for="attachment in message.attachments"
@@ -219,7 +217,11 @@ export default {
@error="onVideoLoadError" @error="onVideoLoadError"
/> />
<audio v-else-if="attachment.file_type === 'audio'" controls> <audio
v-else-if="attachment.file_type === 'audio'"
controls
class="h-10 dark:invert"
>
<source :src="attachment.data_url" /> <source :src="attachment.data_url" />
</audio> </audio>
<FileBubble v-else :url="attachment.data_url" /> <FileBubble v-else :url="attachment.data_url" />
@@ -236,8 +238,7 @@ export default {
<p <p
v-if="message.showAvatar || hasRecordedResponse" v-if="message.showAvatar || hasRecordedResponse"
v-dompurify-html="agentName" v-dompurify-html="agentName"
class="agent-name" class="agent-name text-n-slate-11"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
/> />
</div> </div>
</div> </div>

View File

@@ -6,7 +6,6 @@ import ChatOptions from 'shared/components/ChatOptions.vue';
import ChatArticle from './template/Article.vue'; import ChatArticle from './template/Article.vue';
import EmailInput from './template/EmailInput.vue'; import EmailInput from './template/EmailInput.vue';
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction.vue'; import CustomerSatisfaction from 'shared/components/CustomerSatisfaction.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import IntegrationCard from './template/IntegrationCard.vue'; import IntegrationCard from './template/IntegrationCard.vue';
export default { export default {
@@ -33,13 +32,11 @@ export default {
setup() { setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } = const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter(); useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { return {
formatMessage, formatMessage,
getPlainText, getPlainText,
truncateMessage, truncateMessage,
highlightContent, highlightContent,
getThemeClass,
}; };
}, },
computed: { computed: {
@@ -98,12 +95,11 @@ export default {
v-if=" v-if="
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT !isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
" "
class="chat-bubble agent" class="chat-bubble agent bg-n-background dark:bg-n-solid-3 text-n-slate-12"
:class="getThemeClass('bg-white', 'dark:bg-slate-700 has-dark-mode')"
> >
<div <div
v-dompurify-html="formatMessage(message, false)" v-dompurify-html="formatMessage(message, false)"
class="message-content text-slate-900 dark:text-slate-50" class="message-content text-n-slate-12"
/> />
<EmailInput <EmailInput
v-if="isTemplateEmail" v-if="isTemplateEmail"

View File

@@ -1,47 +1,30 @@
<script> <script>
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
name: 'AgentTypingBubble', name: 'AgentTypingBubble',
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
}; };
</script> </script>
<template> <template>
<div class="agent-message-wrap"> <div class="agent-message-wrap sticky bottom-1">
<div class="agent-message"> <div class="agent-message">
<div class="avatar-wrap" /> <div class="avatar-wrap" />
<div class="message-wrap mt-2"> <div class="message-wrap mt-2">
<div <div
class="typing-bubble chat-bubble agent" class="chat-bubble agent typing-bubble bg-n-background dark:bg-n-solid-3"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
> >
<img src="assets/images/typing.gif" alt="Agent is typing a message" /> <img
src="assets/images/typing.gif"
alt="Agent is typing a message"
class="!w-full"
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.agent-message-wrap {
position: sticky;
bottom: $space-smaller;
}
.typing-bubble { .typing-bubble {
max-width: $space-normal * 2.4; @apply max-w-[2.4rem] p-2 ltr:rounded-bl-[1.25rem] rtl:rounded-br-[1.25rem] ltr:rounded-tl-lg rtl:rounded-tr-lg;
padding: $space-small;
border-bottom-left-radius: $space-two;
border-top-left-radius: $space-small;
img {
width: 100%;
}
} }
</style> </style>

View File

@@ -1,15 +0,0 @@
<template>
<div class="py-4 space-y-4 bg-white dark:bg-slate-700">
<div class="space-y-2 animate-pulse">
<div class="h-6 bg-slate-100 dark:bg-slate-500 rounded w-2/5" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded w-1/5" />
</div>
</div>
</template>

View File

@@ -1,54 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import ArticleList from './ArticleList.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon, ArticleList },
props: {
title: {
type: String,
default: '',
},
articles: {
type: Array,
default: () => [],
},
},
emits: ['view', 'viewAll'],
computed: {
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
},
methods: {
onArticleClick(link) {
this.$emit('view', link);
},
},
};
</script>
<template>
<div>
<h3 class="mb-0 text-sm font-medium text-slate-800 dark:text-slate-50">
{{ title }}
</h3>
<ArticleList :articles="articles" @select-article="onArticleClick" />
<button
class="inline-flex items-center justify-between px-2 py-1 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800 see-articles"
:style="{ color: widgetColor }"
@click="$emit('viewAll')"
>
<span class="pr-2 text-sm">{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
<FluentIcon icon="arrow-right" size="14" />
</button>
</div>
</template>
<style lang="scss" scoped>
.see-articles {
color: var(--brand-textButtonClear);
svg {
color: var(--brand-textButtonClear);
}
}
</style>

View File

@@ -1,27 +0,0 @@
<script>
import CategoryCard from './ArticleCategoryCard.vue';
export default {
components: { CategoryCard },
props: {
articles: {
type: Array,
default: () => [],
},
},
emits: ['view', 'viewAll'],
methods: {
onArticleClick(link) {
this.$emit('view', link);
},
},
};
</script>
<template>
<CategoryCard
:title="$t('PORTAL.POPULAR_ARTICLES')"
:articles="articles.slice(0, 6)"
@view-all="$emit('viewAll')"
@view="onArticleClick"
/>
</template>

View File

@@ -1,36 +0,0 @@
<script>
import ArticleListItem from './ArticleListItem.vue';
export default {
components: {
ArticleListItem,
},
props: {
articles: {
type: Array,
default: () => [],
},
},
emits: ['selectArticle'],
data() {
return {};
},
methods: {
onClick(link) {
this.$emit('selectArticle', link);
},
},
};
</script>
<template>
<ul role="list" class="py-2">
<ArticleListItem
v-for="article in articles"
:key="article.slug"
:link="article.link"
:title="article.title"
@select-article="onClick"
/>
</ul>
</template>

View File

@@ -1,41 +0,0 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon },
props: {
link: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
},
emits: ['selectArticle'],
data() {
return {};
},
methods: {
onClick() {
this.$emit('selectArticle', this.link);
},
},
};
</script>
<template>
<li
class="py-1 flex items-center justify-between -mx-1 px-1 hover:bg-slate-25 dark:hover:bg-slate-600 rounded cursor-pointer text-slate-700 dark:text-slate-50 dark:hover:text-slate-25 hover:text-slate-900"
role="button"
@click="onClick"
>
<button class="underline-offset-2 text-sm leading-6 text-left">
{{ title }}
</button>
<span class="pl-1 arrow">
<FluentIcon icon="arrow-right" size="14" />
</span>
</li>
</template>

View File

@@ -1,52 +0,0 @@
<script>
import { debounce } from '@chatwoot/utils';
export default {
props: {
placeholder: {
type: String,
default: '',
},
},
emits: ['search'],
data() {
return {
searchQuery: '',
};
},
methods: {
handleInput: debounce(
() => {
this.$emit('search', this.searchQuery);
},
500,
true
),
},
};
</script>
<template>
<div class="relative flex items-center">
<div
class="absolute inset-y-0 left-0 flex items-center px-2 py-2 text-slate-500"
>
<fluent-icon icon="search" size="14" />
</div>
<input
id="search"
v-model="searchQuery"
:placeholder="placeholder"
type="text"
name="search"
class="block w-full h-8 px-2 pl-6 pr-1 text-sm border rounded-md focus-visible:outline-none text-slate-800 border-slate-100 bg-slate-75 placeholder:text-slate-400 focus:ring focus:border-woot-500 focus:ring-woot-200 hover:border-woot-200"
@input="handleInput"
/>
<div class="absolute inset-y-0 right-0 flex py-1.5 pr-1.5">
<kbd
class="inline-flex items-center px-1 font-sans border rounded border-slate-200 text-xxs text-slate-400"
>
{{ '⌘K' }}
</kbd>
</div>
</div>
</template>

View File

@@ -1,27 +0,0 @@
<script>
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
export default {
name: 'AvailableAgents',
components: { GroupedAvatars },
props: {
agents: {
type: Array,
default: () => [],
},
},
computed: {
users() {
return this.agents.slice(0, 4).map(agent => ({
id: agent.id,
avatar: agent.avatar_url,
name: agent.name,
}));
},
},
};
</script>
<template>
<GroupedAvatars :users="users" />
</template>

View File

@@ -33,21 +33,15 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.banner { .banner {
color: $color-white; @apply text-white text-sm font-semibold p-3 text-center;
font-size: $font-size-default;
font-weight: $font-weight-bold;
padding: $space-slab;
text-align: center;
&.success { &.success {
background: $color-success; @apply bg-n-teal-9;
} }
&.error { &.error {
background: $color-error; @apply bg-n-ruby-9;
} }
} }
</style> </style>

View File

@@ -147,7 +147,7 @@ export default {
}" }"
@input-file="onFileUpload" @input-file="onFileUpload"
> >
<button class="icon-button flex items-center justify-center"> <button class="min-h-8 min-w-8 flex items-center justify-center">
<FluentIcon v-if="!isUploading.image" icon="attach" /> <FluentIcon v-if="!isUploading.image" icon="attach" />
<Spinner v-if="isUploading" size="small" /> <Spinner v-if="isUploading" size="small" />
</button> </button>

View File

@@ -156,23 +156,3 @@ export default {
</CustomButton> </CustomButton>
</div> </div>
</template> </template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.branding {
align-items: center;
color: $color-body;
display: flex;
font-size: $font-size-default;
justify-content: center;
padding: $space-one;
text-align: center;
text-decoration: none;
img {
margin-right: $space-small;
max-width: $space-two;
}
}
</style>

View File

@@ -4,7 +4,6 @@ import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
name: 'ChatHeader', name: 'ChatHeader',
@@ -35,10 +34,6 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: { computed: {
isOnline() { isOnline() {
const { workingHoursEnabled } = this.channelConfig; const { workingHoursEnabled } = this.channelConfig;
@@ -59,43 +54,32 @@ export default {
</script> </script>
<template> <template>
<header <header class="flex justify-between w-full p-5 bg-n-background gap-2">
class="flex justify-between w-full p-5"
:class="getThemeClass('bg-white', 'dark:bg-slate-900')"
>
<div class="flex items-center"> <div class="flex items-center">
<button <button
v-if="showBackButton" v-if="showBackButton"
class="px-2 -ml-3" class="px-2 ltr:-ml-3 rtl:-mr-3"
@click="onBackButtonClick" @click="onBackButtonClick"
> >
<FluentIcon <FluentIcon icon="chevron-left" size="24" class="text-n-slate-12" />
icon="chevron-left"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<img <img
v-if="avatarUrl" v-if="avatarUrl"
class="w-8 h-8 mr-3 rounded-full" class="w-8 h-8 ltr:mr-3 rtl:ml-3 rounded-full"
:src="avatarUrl" :src="avatarUrl"
alt="avatar" alt="avatar"
/> />
<div> <div class="flex flex-col gap-1">
<div <div
class="flex items-center text-base font-medium leading-4" class="flex items-center text-base font-medium leading-4 text-n-slate-12"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
> >
<span v-dompurify-html="title" class="mr-1" /> <span v-dompurify-html="title" class="ltr:mr-1 rtl:ml-1" />
<div <div
:class="`h-2 w-2 rounded-full :class="`h-2 w-2 rounded-full
${isOnline ? 'bg-green-500' : 'hidden'}`" ${isOnline ? 'bg-green-500' : 'hidden'}`"
/> />
</div> </div>
<div <div class="text-xs leading-3 text-n-slate-11">
class="mt-1 text-xs leading-3"
:class="getThemeClass('text-black-700', 'dark:text-slate-400')"
>
{{ replyWaitMessage }} {{ replyWaitMessage }}
</div> </div>
</div> </div>

View File

@@ -1,45 +1,36 @@
<script> <script setup>
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import { useDarkMode } from 'widget/composables/useDarkMode'; import { computed } from 'vue';
export default { const props = defineProps({
name: 'ChatHeaderExpanded', avatarUrl: {
components: { type: String,
HeaderActions, default: '',
}, },
props: { introHeading: {
avatarUrl: { type: String,
type: String, default: '',
default: '',
},
introHeading: {
type: String,
default: '',
},
introBody: {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
}, },
setup() { introBody: {
const { getThemeClass } = useDarkMode(); type: String,
return { getThemeClass }; default: '',
}, },
}; showPopoutButton: {
type: Boolean,
default: false,
},
});
const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end',
]);
</script> </script>
<template> <template>
<header <header
class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent" class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent"
> >
<div <div class="flex items-start" :class="containerClasses">
class="flex items-start"
:class="[avatarUrl ? 'justify-between' : 'justify-end']"
>
<img <img
v-if="avatarUrl" v-if="avatarUrl"
class="h-12 rounded-full" class="h-12 rounded-full"
@@ -53,13 +44,11 @@ export default {
</div> </div>
<h2 <h2
v-dompurify-html="introHeading" v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium" class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
/> />
<p <p
v-dompurify-html="introBody" v-dompurify-html="introBody"
class="text-base leading-normal" class="text-lg leading-normal text-n-slate-11"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
/> />
</header> </header>
</template> </template>

View File

@@ -6,7 +6,6 @@ import ChatSendButton from 'widget/components/ChatSendButton.vue';
import configMixin from '../mixins/configMixin'; import configMixin from '../mixins/configMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue'; import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import EmojiInput from 'shared/components/emoji/EmojiInput.vue'; import EmojiInput from 'shared/components/emoji/EmojiInput.vue';
@@ -30,10 +29,6 @@ export default {
default: () => {}, default: () => {},
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() { data() {
return { return {
userInput: '', userInput: '',
@@ -53,18 +48,6 @@ export default {
showSendButton() { showSendButton() {
return this.userInput.length > 0; return this.userInput.length > 0;
}, },
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
},
emojiIconColor() {
return this.showEmojiPicker
? `text-woot-500 ${this.getThemeClass(
'text-black-900',
'dark:text-slate-100'
)}`
: `${this.getThemeClass('text-black-900', 'dark:text-slate-100')}`;
},
}, },
watch: { watch: {
isWidgetOpen(isWidgetOpen) { isWidgetOpen(isWidgetOpen) {
@@ -133,8 +116,11 @@ export default {
<template> <template>
<div <div
class="chat-message--input is-focused" class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
:class="getThemeClass('bg-white ', 'dark:bg-slate-600')" :class="{
'!shadow-n-brand dark:!shadow-n-brand': isFocused,
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
}"
@keydown.esc="hideEmojiPicker" @keydown.esc="hideEmojiPicker"
> >
<ResizableTextArea <ResizableTextArea
@@ -144,26 +130,32 @@ export default {
:rows="1" :rows="1"
:aria-label="$t('CHAT_PLACEHOLDER')" :aria-label="$t('CHAT_PLACEHOLDER')"
:placeholder="$t('CHAT_PLACEHOLDER')" :placeholder="$t('CHAT_PLACEHOLDER')"
class="form-input user-message-input is-focused" class="user-message-input reset-base"
:class="inputColor"
@typing-off="onTypingOff" @typing-off="onTypingOff"
@typing-on="onTypingOn" @typing-on="onTypingOn"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
/> />
<div class="button-wrap"> <div class="flex items-center ltr:pl-2 rtl:pr-2">
<ChatAttachmentButton <ChatAttachmentButton
v-if="showAttachment" v-if="showAttachment"
:class="getThemeClass('text-black-900', 'dark:text-slate-100')" class="text-n-slate-12"
:on-attach="onSendAttachment" :on-attach="onSendAttachment"
/> />
<button <button
v-if="hasEmojiPickerEnabled" v-if="hasEmojiPickerEnabled"
class="flex items-center justify-center icon-button" class="flex items-center justify-center min-h-8 min-w-8"
:aria-label="$t('EMOJI.ARIA_LABEL')" :aria-label="$t('EMOJI.ARIA_LABEL')"
@click="toggleEmojiPicker" @click="toggleEmojiPicker"
> >
<FluentIcon icon="emoji" :class="emojiIconColor" /> <FluentIcon
icon="emoji"
class="transition-all duration-150"
:class="{
'text-n-slate-12': !showEmojiPicker,
'text-n-brand': showEmojiPicker,
}"
/>
</button> </button>
<EmojiInput <EmojiInput
v-if="showEmojiPicker" v-if="showEmojiPicker"
@@ -181,46 +173,11 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
@import 'widget/assets/scss/mixins.scss';
.chat-message--input {
align-items: center;
display: flex;
padding: 0 $space-small 0 $space-slab;
border-radius: 7px;
&.is-focused {
box-shadow:
0 0 0 1px $color-woot,
0 0 2px 3px $color-primary-light;
}
}
.emoji-dialog { .emoji-dialog {
right: 20px; @apply max-w-full ltr:right-5 rtl:right-[unset] rtl:left-5 -top-[302px] before:ltr:right-2.5 before:rtl:right-[unset] before:rtl:left-2.5;
top: -302px;
max-width: 100%;
&::before {
right: $space-one;
}
}
.button-wrap {
display: flex;
align-items: center;
padding-left: $space-small;
} }
.user-message-input { .user-message-input {
border: 0; @apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-n-background text-n-slate-12 transition-all duration-200;
height: $space-large;
min-height: $space-large;
max-height: 2.4 * $space-mega;
resize: none;
padding: $space-smaller 0;
margin-top: $space-small;
margin-bottom: $space-small;
} }
</style> </style>

View File

@@ -53,56 +53,3 @@ export default {
max-width: 90%; max-width: 90%;
} }
</style> </style>
<style lang="scss">
@import 'widget/assets/scss/variables.scss';
.chat-bubble .message-content,
.chat-bubble.user {
p code {
background-color: var(--s-75);
display: inline-block;
line-height: 1;
border-radius: $border-radius-small;
padding: $space-smaller;
}
pre {
overflow-y: auto;
background-color: var(--s-75);
border-color: var(--s-75);
color: var(--s-800);
border-radius: $border-radius-normal;
padding: $space-small;
margin-top: $space-smaller;
margin-bottom: $space-small;
display: block;
line-height: 1.7;
white-space: pre-wrap;
code {
background-color: transparent;
color: var(--s-800);
padding: 0;
}
}
blockquote {
border-left: $space-micro solid var(--s-75);
color: var(--s-800);
padding: $space-smaller $space-small;
margin: $space-smaller 0;
padding: $space-small $space-small 0 $space-normal;
}
}
@media (prefers-color-scheme: dark) {
.chat-bubble.agent.has-dark-mode {
blockquote {
border-color: var(--s-200);
color: var(--s-50);
}
}
}
</style>

View File

@@ -28,7 +28,7 @@ export default {
<button <button
type="submit" type="submit"
:disabled="disabled" :disabled="disabled"
class="icon-button flex items-center justify-center ml-1" class="min-h-8 min-w-8 flex items-center justify-center ml-1"
> >
<FluentIcon v-if="!loading" icon="send" :style="`color: ${color}`" /> <FluentIcon v-if="!loading" icon="send" :style="`color: ${color}`" />
<Spinner v-else size="small" /> <Spinner v-else size="small" />

View File

@@ -1,62 +0,0 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon },
props: {
title: {
type: String,
default: 'Continue your chat',
},
content: {
type: String,
default: 'Chat with us',
},
unreadCount: {
type: Number,
default: 0,
},
},
emits: ['continue'],
};
</script>
<template>
<button
type="button"
class="flex w-full justify-between items-center rounded-md ring-1 ring-inset ring-slate-50 px-2 py-2 text-sm text-slate-700 bg-slate-25 hover:bg-slate-50 dark:text-white dark:bg-slate-800 dark:ring-slate-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-woot-600 group"
@click="$emit('continue')"
>
<div
class="w-10 h-10 rounded-md bg-slate-75 dark:bg-slate-700 text-lg flex items-center justify-center flex-shrink-0"
>
<FluentIcon
icon="chat"
size="16"
class="text-slate-600 dark:text-slate-400"
/>
</div>
<div
class="text-left flex flex-col justify-start flex-grow max-w-[calc(100%-80px)] mx-2 group-hover:opacity-75"
>
<h5 class="font-medium text-slate-900 dark:text-white">
{{ title }}
</h5>
<p class="h-4 leading-4 flex items-center gap-1">
<span
v-if="unreadCount > 0"
class="inline-flex items-center justify-center rounded-full bg-green-200 px-1 min-w-[16px] leading-4 text-xxs font-medium text-green-700 mr-0.5"
>
{{ unreadCount }}
</span>
<span
v-dompurify-html="content"
class="leading-4 h-4 text-ellipsis overflow-hidden whitespace-nowrap dark:text-slate-25"
/>
</p>
</div>
<div class="w-8 h-10 flex items-center justify-center">
<FluentIcon icon="chevron-right" />
</div>
</button>
</template>

View File

@@ -122,9 +122,6 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
@import 'widget/assets/scss/mixins.scss';
.conversation--container { .conversation--container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -143,7 +140,7 @@ export default {
.conversation-wrap { .conversation-wrap {
flex: 1; flex: 1;
padding: $space-large $space-small $space-small $space-small; @apply px-2 pt-8 pb-2;
} }
.message--loader { .message--loader {

View File

@@ -1,6 +1,5 @@
<script> <script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
export default { export default {
@@ -25,10 +24,6 @@ export default {
default: false, default: false,
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: { computed: {
title() { title() {
return this.isInProgress return this.isInProgress
@@ -46,11 +41,6 @@ export default {
? this.contrastingTextColor ? this.contrastingTextColor
: ''; : '';
}, },
titleColor() {
return !this.isUserBubble
? this.getThemeClass('text-black-900', 'dark:text-slate-50')
: '';
},
}, },
methods: { methods: {
openLink() { openLink() {
@@ -66,11 +56,15 @@ export default {
<div class="icon-wrap" :style="{ color: textColor }"> <div class="icon-wrap" :style="{ color: textColor }">
<FluentIcon icon="document" size="28" /> <FluentIcon icon="document" size="28" />
</div> </div>
<div class="meta"> <div class="ltr:pr-1 rtl:pl-1">
<div class="title" :class="titleColor" :style="{ color: textColor }"> <div
class="m-0 font-medium text-sm"
:class="{ 'text-n-slate-12': !isUserBubble }"
:style="{ color: textColor }"
>
{{ title }} {{ title }}
</div> </div>
<div class="link-wrap mb-1"> <div class="leading-none mb-1">
<a <a
class="download" class="download"
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
@@ -86,38 +80,13 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.file { .file {
.icon-wrap { .icon-wrap {
font-size: $font-size-mega; @apply text-[2.5rem] text-n-brand leading-none ltr:ml-1 rtl:mr-1 ltr:mr-2 rtl:ml-2;
color: $color-woot;
line-height: 1;
margin-left: $space-smaller;
margin-right: $space-small;
}
.title {
font-weight: $font-weight-medium;
font-size: $font-size-default;
margin: 0;
} }
.download { .download {
color: $color-woot; @apply text-n-brand font-medium p-0 m-0 text-xs no-underline;
font-weight: $font-weight-medium;
padding: 0;
margin: 0;
font-size: $font-size-small;
text-decoration: none;
}
.link-wrap {
line-height: 1;
}
.meta {
padding-right: $space-smaller;
} }
} }
</style> </style>

View File

@@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, useTemplateRef, nextTick, unref } from 'vue'; import { ref, computed, watch, useTemplateRef, nextTick, unref } from 'vue';
import countriesList from 'shared/constants/countries.js'; import countriesList from 'shared/constants/countries.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { import {
getActiveCountryCode, getActiveCountryCode,
@@ -17,8 +16,6 @@ const { context } = defineProps({
const localValue = ref(context.value || ''); const localValue = ref(context.value || '');
const { getThemeClass: $dm } = useDarkMode();
const selectedIndex = ref(-1); const selectedIndex = ref(-1);
const showDropdown = ref(false); const showDropdown = ref(false);
const searchCountry = ref(''); const searchCountry = ref('');
@@ -30,7 +27,7 @@ const dropdownRef = useTemplateRef('dropdownRef');
const searchbarRef = useTemplateRef('searchbarRef'); const searchbarRef = useTemplateRef('searchbarRef');
const placeholder = computed(() => context?.attrs?.placeholder || ''); const placeholder = computed(() => context?.attrs?.placeholder || '');
const hasErrorInPhoneInput = computed(() => context.hasErrorInPhoneInput); const hasErrorInPhoneInput = computed(() => context?.state?.invalid);
const dropdownFirstItemName = computed(() => const dropdownFirstItemName = computed(() =>
activeCountryCode.value ? 'Clear selection' : 'Select Country' activeCountryCode.value ? 'Clear selection' : 'Select Country'
); );
@@ -44,43 +41,6 @@ const countries = computed(() => [
...countriesList, ...countriesList,
]); ]);
const dropdownClass = computed(() =>
$dm('bg-slate-100 text-slate-700', 'dark:bg-slate-700 dark:text-slate-50')
);
const dropdownBackgroundClass = computed(() =>
$dm('bg-white text-slate-700', 'dark:bg-slate-700 dark:text-slate-50')
);
const dropdownItemClass = computed(() =>
$dm(
'text-slate-700 hover:bg-slate-50',
'dark:text-slate-50 dark:hover:bg-slate-600'
)
);
const activeDropdownItemClass = computed(
() => `active ${$dm('bg-slate-100', 'dark:bg-slate-800')}`
);
const focusedDropdownItemClass = computed(
() => `focus ${$dm('bg-slate-50', 'dark:bg-slate-600')}`
);
const inputLightAndDarkModeColor = computed(() =>
$dm('bg-white text-slate-700', 'dark:bg-slate-600 dark:text-slate-50')
);
const inputBorderColor = computed(
() => `${$dm('border-black-200', 'dark:border-black-500')}`
);
const inputHasError = computed(() =>
hasErrorInPhoneInput.value
? `border-red-200 hover:border-red-300 focus:border-red-300 ${inputLightAndDarkModeColor.value}`
: `hover:border-black-300 focus:border-black-300 ${inputLightAndDarkModeColor.value} ${inputBorderColor.value}`
);
const items = computed(() => { const items = computed(() => {
return countries.value.filter(country => { return countries.value.filter(country => {
const { name, dial_code, id } = country; const { name, dial_code, id } = country;
@@ -206,12 +166,15 @@ function onSelect() {
<template> <template>
<div class="relative mt-2 phone-input--wrap"> <div class="relative mt-2 phone-input--wrap">
<div <div
class="flex items-center justify-start w-full border border-solid rounded outline-none phone-input" class="flex items-center justify-start outline-none phone-input rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] text-sm w-full text-n-slate-12 focus-within:outline-n-brand focus-within:ring-1 focus-within:ring-n-brand"
:class="inputHasError" :class="{
'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9':
hasErrorInPhoneInput,
'outline-n-weak': !hasErrorInPhoneInput,
}"
> >
<div <div
class="flex items-center justify-between h-full px-2 py-2 cursor-pointer country-emoji--wrap" class="flex items-center justify-between h-[2.625rem] px-2 py-2 cursor-pointer bg-n-alpha-1 dark:bg-n-solid-1 ltr:rounded-bl-lg rtl:rounded-br-lg ltr:rounded-tl-lg rtl:rounded-tr-lg min-w-[3.6rem] w-[3.6rem]"
:class="dropdownClass"
@click="toggleCountryDropdown" @click="toggleCountryDropdown"
> >
<h5 v-if="activeCountry.emoji" class="mb-0 text-xl"> <h5 v-if="activeCountry.emoji" class="mb-0 text-xl">
@@ -222,18 +185,16 @@ function onSelect() {
</div> </div>
<span <span
v-if="activeDialCode" v-if="activeDialCode"
class="py-2 pl-2 pr-0 text-base" class="py-2 ltr:pl-2 rtl:pr-2 text-base text-n-slate-11"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
> >
{{ activeDialCode }} {{ activeDialCode }}
</span> </span>
<input <input
:value="phoneNumber" :value="phoneNumber"
type="phoneInput" type="phoneInput"
class="w-full h-full py-2 pl-2 pr-3 leading-tight border-0 rounded-r outline-none" class="w-full h-full !py-3 pl-2 pr-3 leading-tight rounded-r !outline-none focus:!ring-0 !bg-transparent dark:!bg-transparent"
name="phoneNumber" name="phoneNumber"
:placeholder="placeholder" :placeholder="placeholder"
:class="inputLightAndDarkModeColor"
@input="onChange" @input="onChange"
@blur="context.blurHandler" @blur="context.blurHandler"
/> />
@@ -242,30 +203,30 @@ function onSelect() {
v-if="showDropdown" v-if="showDropdown"
ref="dropdownRef" ref="dropdownRef"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
:class="dropdownBackgroundClass" class="country-dropdown absolute bg-n-background text-n-slate-12 dark:bg-n-solid-3 z-10 h-48 px-0 pt-0 pb-1 pl-1 pr-1 overflow-y-auto rounded-lg shadow-lg top-12 w-full min-w-24 max-w-[14.8rem]"
class="absolute z-10 h-48 px-0 pt-0 pb-1 pl-1 pr-1 overflow-y-auto rounded shadow-lg country-dropdown top-12"
@keydown.up="moveSelectionUp" @keydown.up="moveSelectionUp"
@keydown.down="moveSelectionDown" @keydown.down="moveSelectionDown"
@keydown.enter="onSelect" @keydown.enter="onSelect"
> >
<div class="sticky top-0" :class="dropdownBackgroundClass"> <div
class="sticky top-0 bg-n-background text-n-slate-12 dark:bg-n-solid-3"
>
<input <input
ref="searchbarRef" ref="searchbarRef"
v-model="searchCountry" v-model="searchCountry"
type="text" type="text"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_SEARCH')" :placeholder="$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_SEARCH')"
class="w-full h-8 px-3 py-2 mt-1 mb-1 text-sm border border-solid rounded outline-none dropdown-search" class="w-full h-8 !ring-0 px-3 py-2 mt-1 mb-1 text-sm rounded bg-n-alpha-black2"
:class="[$dm('bg-slate-50', 'dark:bg-slate-600'), inputBorderColor]"
/> />
</div> </div>
<div <div
v-for="(country, index) in items" v-for="(country, index) in items"
:key="index" :key="index"
class="flex items-center h-8 px-2 py-2 rounded cursor-pointer country-dropdown--item" class="flex items-center h-8 px-2 py-2 rounded cursor-pointer country-dropdown--item text-n-slate-12 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2"
:class="[ :class="[
dropdownItemClass, country.id === activeCountryCode &&
country.id === activeCountryCode ? activeDropdownItemClass : '', 'active bg-n-alpha-1 dark:bg-n-solid-1',
index === selectedIndex ? focusedDropdownItemClass : '', index === selectedIndex && 'focus dark:bg-n-solid-2 bg-n-alpha-2',
]" ]"
@click="onSelectCountry(country)" @click="onSelectCountry(country)"
> >
@@ -279,8 +240,7 @@ function onSelect() {
</div> </div>
<div v-if="items.length === 0"> <div v-if="items.length === 0">
<span <span
class="flex justify-center mt-4 text-sm text-center" class="flex justify-center mt-4 text-sm text-center text-n-slate-11"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
> >
{{ $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_EMPTY') }} {{ $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_EMPTY') }}
</span> </span>
@@ -288,30 +248,3 @@ function onSelect() {
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.phone-input--wrap {
.phone-input {
height: 2.8rem;
input:placeholder-shown {
text-overflow: ellipsis;
}
}
.country-emoji--wrap {
border-bottom-left-radius: 0.18rem;
border-top-left-radius: 0.18rem;
min-width: 3.6rem;
width: 3.6rem;
}
.country-dropdown {
min-width: 6rem;
max-width: 14.8rem;
width: 100%;
}
}
</style>

View File

@@ -1,31 +1,33 @@
<script> <script setup>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { defineProps, computed } from 'vue';
export default { const props = defineProps({
name: 'GroupedAvatars', users: {
components: { Thumbnail }, type: Array,
props: { default: () => [],
users: {
type: Array,
default: () => [],
},
}, },
}; limit: {
type: Number,
default: 4,
},
});
const usersToDisplay = computed(() => props.users.slice(0, props.limit));
</script> </script>
<template> <template>
<div class="flex"> <div class="flex">
<span <span
v-for="(user, index) in users" v-for="(user, index) in usersToDisplay"
:key="user.id" :key="user.id"
:class="`${ :class="index ? 'ltr:-ml-4 rtl:-mr-4' : ''"
index ? '-ml-4' : '' class="inline-block rounded-full text-white shadow-solid"
} inline-block rounded-full text-white shadow-solid`"
> >
<Thumbnail <Thumbnail
size="36px" size="36px"
:username="user.name" :username="user.name"
:src="user.avatar" :src="user.avatar_url"
has-border has-border
/> />
</span> </span>

View File

@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils'; import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { popoutChatWindow } from '../helpers/popoutHelper'; import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import { CONVERSATION_STATUS } from 'shared/constants/messages'; import { CONVERSATION_STATUS } from 'shared/constants/messages';
@@ -21,10 +20,6 @@ export default {
default: true, default: true,
}, },
}, },
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: { computed: {
...mapGetters({ ...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams', conversationAttributes: 'conversationAttributes/getConversationParams',
@@ -83,7 +78,7 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<div v-if="showHeaderActions" class="actions flex items-center"> <div v-if="showHeaderActions" class="actions flex items-center gap-3">
<button <button
v-if=" v-if="
canLeaveConversation && canLeaveConversation &&
@@ -94,22 +89,14 @@ export default {
:title="$t('END_CONVERSATION')" :title="$t('END_CONVERSATION')"
@click="resolveConversation" @click="resolveConversation"
> >
<FluentIcon <FluentIcon icon="sign-out" size="22" class="text-n-slate-12" />
icon="sign-out"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<button <button
v-if="showPopoutButton" v-if="showPopoutButton"
class="button transparent compact new-window--button" class="button transparent compact new-window--button"
@click="popoutWindow" @click="popoutWindow"
> >
<FluentIcon <FluentIcon icon="open" size="22" class="text-n-slate-12" />
icon="open"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
<button <button
class="button transparent compact close-button" class="button transparent compact close-button"
@@ -118,28 +105,13 @@ export default {
}" }"
@click="closeWindow" @click="closeWindow"
> >
<FluentIcon <FluentIcon icon="dismiss" size="24" class="text-n-slate-12" />
icon="dismiss"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
/>
</button> </button>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.actions { .actions {
button {
margin-left: $space-normal;
}
span {
color: $color-heading;
font-size: $font-size-large;
}
.close-button { .close-button {
display: none; display: none;
} }

View File

@@ -29,8 +29,6 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.image { .image {
display: block; display: block;
@@ -40,11 +38,7 @@ export default {
max-width: 100%; max-width: 100%;
&::before { &::before {
background-image: linear-gradient( background-image: linear-gradient(-180deg, transparent 3%, #1f2d3d 130%);
-180deg,
transparent 3%,
$color-heading 130%
);
bottom: 0; bottom: 0;
content: ''; content: '';
height: 20%; height: 20%;
@@ -61,12 +55,7 @@ export default {
} }
.time { .time {
font-size: $font-size-small; @apply text-xs bottom-1 text-white ltr:right-3 rtl:left-3 whitespace-nowrap absolute;
bottom: $space-smaller;
color: $color-white;
position: absolute;
right: $space-slab;
white-space: nowrap;
} }
} }
</style> </style>

View File

@@ -7,7 +7,6 @@ import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators'; import { getRegexp } from 'shared/helpers/Validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import { FormKit, createInput } from '@formkit/vue'; import { FormKit, createInput } from '@formkit/vue';
import PhoneInput from 'widget/components/Form/PhoneInput.vue'; import PhoneInput from 'widget/components/Form/PhoneInput.vue';
@@ -31,9 +30,8 @@ export default {
props: ['hasErrorInPhoneInput'], props: ['hasErrorInPhoneInput'],
}); });
const { formatMessage } = useMessageFormatter(); const { formatMessage } = useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { formatMessage, phoneInput, getThemeClass }; return { formatMessage, phoneInput };
}, },
data() { data() {
return { return {
@@ -66,7 +64,10 @@ export default {
return !isEmptyObject(this.activeCampaign); return !isEmptyObject(this.activeCampaign);
}, },
shouldShowHeaderMessage() { shouldShowHeaderMessage() {
return this.hasActiveCampaign || this.preChatFormEnabled; return (
this.hasActiveCampaign ||
(this.preChatFormEnabled && !!this.headerMessage)
);
}, },
headerMessage() { headerMessage() {
if (this.preChatFormEnabled) { if (this.preChatFormEnabled) {
@@ -138,35 +139,12 @@ export default {
}); });
return contactAttributes; return contactAttributes;
}, },
inputStyles() {
return `mt-1 border rounded w-full py-2 px-3 text-slate-700 outline-none`;
},
isInputDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
},
}, },
methods: { methods: {
labelClass(context) { labelClass(input) {
const { hasErrors } = context; const { state } = input.context;
if (!hasErrors) { const hasErrors = state.invalid;
return `text-xs font-medium ${this.getThemeClass( return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10';
'text-black-800',
'dark:text-slate-50'
)}`;
}
return `text-xs font-medium ${this.getThemeClass(
'text-red-400',
'dark:text-red-400'
)}`;
}, },
inputClass(input) { inputClass(input) {
const { state, family: classification, type } = input.context; const { state, family: classification, type } = input.context;
@@ -178,9 +156,9 @@ export default {
this.hasErrorInPhoneInput = hasErrors; this.hasErrorInPhoneInput = hasErrors;
} }
if (!hasErrors) { if (!hasErrors) {
return `${this.inputStyles} hover:border-black-300 focus:border-black-300 ${this.isInputDarkOrLightMode} ${this.inputBorderColor}`; return `mt-1 rounded w-full py-2 px-3`;
} }
return `${this.inputStyles} border-red-200 hover:border-red-300 focus:border-red-300 ${this.isInputDarkOrLightMode}`; return `mt-1 rounded w-full py-2 px-3 error`;
}, },
isContactFieldRequired(field) { isContactFieldRequired(field) {
return this.preChatFields.find(option => option.name === field).required; return this.preChatFields.find(option => option.name === field).required;
@@ -286,8 +264,7 @@ export default {
<div <div
v-if="shouldShowHeaderMessage" v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)" v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-sm leading-5 pre-chat-header-message" class="mb-4 text-base leading-5 pre-chat-header-message text-n-slate-12"
:class="getThemeClass('text-black-800', 'dark:text-slate-50')"
/> />
<!-- Why do the v-bind shenanigan? Because Formkit API is really bad. <!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
If we just pass the options as is even with null or undefined or false, If we just pass the options as is even with null or undefined or false,
@@ -307,7 +284,7 @@ export default {
} }
: undefined : undefined
" "
:label-class="context => labelClass(context)" :label-class="context => `text-sm font-medium ${labelClass(context)}`"
:input-class="context => inputClass(context)" :input-class="context => inputClass(context)"
:validation-messages="{ :validation-messages="{
startsWithPlus: $t( startsWithPlus: $t(
@@ -326,7 +303,7 @@ export default {
v-if="!hasActiveCampaign" v-if="!hasActiveCampaign"
name="message" name="message"
type="textarea" type="textarea"
:label-class="context => labelClass(context)" :label-class="context => `text-sm font-medium ${labelClass(context)}`"
:input-class="context => inputClass(context)" :input-class="context => inputClass(context)"
:label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')" :label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')" :placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
@@ -337,7 +314,7 @@ export default {
/> />
<CustomButton <CustomButton
class="mt-2 mb-5 font-medium" class="mt-3 mb-5 font-medium flex items-center justify-center gap-2"
block block
:bg-color="widgetColor" :bg-color="widgetColor"
:text-color="textColor" :text-color="textColor"
@@ -352,10 +329,22 @@ export default {
<style lang="scss"> <style lang="scss">
.formkit-outer { .formkit-outer {
@apply mt-2; @apply mt-2;
.formkit-inner {
input.error,
textarea.error,
select.error {
@apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
}
input[type='checkbox'] {
@apply size-4 outline-none;
}
}
} }
[data-invalid] .formkit-message { [data-invalid] .formkit-message {
@apply text-red-500 block text-xs font-normal mb-1 w-full; @apply text-n-ruby-10 block text-xs font-normal my-0.5 w-full;
} }
.formkit-outer[data-type='checkbox'] .formkit-wrapper { .formkit-outer[data-type='checkbox'] .formkit-wrapper {

View File

@@ -1,52 +0,0 @@
<script>
import { debounce } from '@chatwoot/utils';
export default {
props: {
placeholder: {
type: String,
default: '',
},
},
emits: ['search'],
data() {
return {
searchQuery: '',
};
},
methods: {
handleInput: debounce(
() => {
this.$emit('search', this.searchQuery);
},
500,
true
),
},
};
</script>
<template>
<div class="relative flex items-center">
<div
class="absolute inset-y-0 left-0 flex items-center h-8 px-2 text-slate-500"
>
<fluent-icon icon="search" size="14" />
</div>
<input
id="search"
v-model="searchQuery"
:placeholder="placeholder"
type="text"
name="search"
class="block w-full h-8 px-2 pl-6 pr-1 m-0 text-sm border rounded-md focus-visible:outline-none text-slate-800 border-slate-100 bg-slate-75 placeholder:text-slate-400 focus:ring focus:border-woot-500 focus:ring-woot-200 hover:border-woot-200"
@input="handleInput"
/>
<div class="absolute inset-y-0 right-0 flex h-8 p-1">
<kbd
class="inline-flex items-center px-1 font-sans border rounded border-slate-200 text-xxs text-slate-400"
>
{{ '⌘K' }}
</kbd>
</div>
</div>
</template>

View File

@@ -2,18 +2,16 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime'; import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability'; import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { IFrameHelper } from 'widget/helpers/utils'; import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
export default { export default {
name: 'TeamAvailability', name: 'TeamAvailability',
components: { components: {
AvailableAgents, GroupedAvatars,
FluentIcon,
}, },
mixins: [configMixin, nextAvailabilityTime, availabilityMixin], mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
props: { props: {
@@ -35,6 +33,13 @@ export default {
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },
agentAvatars() {
return this.availableAgents.map(agent => ({
name: agent.name,
avatar: agent.avatar_url,
id: agent.id,
}));
},
isOnline() { isOnline() {
const { workingHoursEnabled } = this.channelConfig; const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0; const anyAgentOnline = this.availableAgents.length > 0;
@@ -61,35 +66,37 @@ export default {
</script> </script>
<template> <template>
<div class="p-4 bg-white rounded-md shadow-sm dark:bg-slate-700"> <div
<div class="flex items-center justify-between"> class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
<div class=""> >
<div class="text-sm font-medium text-slate-700 dark:text-slate-50"> <div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12">
{{ {{
isOnline isOnline
? $t('TEAM_AVAILABILITY.ONLINE') ? $t('TEAM_AVAILABILITY.ONLINE')
: $t('TEAM_AVAILABILITY.OFFLINE') : $t('TEAM_AVAILABILITY.OFFLINE')
}} }}
</div> </div>
<div class="mt-1 text-sm text-slate-500 dark:text-slate-100"> <div class="text-n-slate-11">
{{ replyWaitMessage }} {{ replyWaitMessage }}
</div> </div>
</div> </div>
<AvailableAgents v-if="isOnline" :agents="availableAgents" /> <GroupedAvatars v-if="isOnline" :users="availableAgents" />
</div> </div>
<button <button
class="inline-flex items-center justify-between px-2 py-1 mt-2 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800" class="inline-flex items-center gap-1 font-medium text-n-slate-12"
:style="{ color: widgetColor }" :style="{ color: widgetColor }"
@click="startConversation" @click="startConversation"
> >
<span class="pr-2 text-sm"> <span>
{{ {{
hasConversation hasConversation
? $t('CONTINUE_CONVERSATION') ? $t('CONTINUE_CONVERSATION')
: $t('START_CONVERSATION') : $t('START_CONVERSATION')
}} }}
</span> </span>
<FluentIcon icon="arrow-right" size="14" /> <i class="i-lucide-chevron-right size-5 mt-px" />
</button> </button>
</div> </div>
</template> </template>

View File

@@ -9,7 +9,6 @@ import {
} from '../constants/widgetBusEvents'; } from '../constants/widgetBusEvents';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
name: 'UnreadMessage', name: 'UnreadMessage',
components: { Thumbnail }, components: { Thumbnail },
@@ -35,13 +34,11 @@ export default {
setup() { setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } = const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter(); useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { return {
formatMessage, formatMessage,
getPlainText, getPlainText,
truncateMessage, truncateMessage,
highlightContent, highlightContent,
getThemeClass,
}; };
}, },
computed: { computed: {
@@ -96,11 +93,7 @@ export default {
<template> <template>
<div class="chat-bubble-wrap"> <div class="chat-bubble-wrap">
<button <button class="chat-bubble agent bg-white" @click="onClickMessage">
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-50')"
@click="onClickMessage"
>
<div v-if="showSender" class="row--agent-block"> <div v-if="showSender" class="row--agent-block">
<Thumbnail <Thumbnail
:src="avatarUrl" :src="avatarUrl"
@@ -120,29 +113,19 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.chat-bubble { .chat-bubble {
max-width: 85%; @apply max-w-[85%] cursor-pointer p-4;
padding: $space-normal;
cursor: pointer;
} }
.row--agent-block { .row--agent-block {
align-items: center; @apply items-center flex text-left pb-2 text-xs;
display: flex;
text-align: left;
padding-bottom: $space-small;
font-size: $font-size-small;
.agent--name { .agent--name {
font-weight: $font-weight-medium; @apply font-medium ml-1;
margin-left: $space-smaller;
} }
.company--name { .company--name {
color: $color-light-gray; @apply text-n-slate-11 dark:text-n-slate-10 ml-1;
margin-left: $space-smaller;
} }
} }
</style> </style>

View File

@@ -56,7 +56,7 @@ export default {
</script> </script>
<template> <template>
<div class="unread-wrap"> <div class="unread-wrap" dir="ltr">
<div class="close-unread-wrap"> <div class="close-unread-wrap">
<button class="button small close-unread-button" @click="closeFullView"> <button class="button small close-unread-button" @click="closeFullView">
<span class="flex items-center"> <span class="flex items-center">
@@ -87,7 +87,7 @@ export default {
<span <span
class="flex items-center" class="flex items-center"
:class="{ :class="{
'is-background-light': isBackgroundLighter, '!text-n-slate-12': isBackgroundLighter,
}" }"
:style="{ :style="{
color: widgetColor, color: widgetColor,
@@ -102,8 +102,6 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables';
.unread-wrap { .unread-wrap {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -116,42 +114,17 @@ export default {
overflow: hidden; overflow: hidden;
.unread-messages { .unread-messages {
padding-bottom: $space-small; @apply pb-2;
} }
.clear-button { .clear-button {
background: transparent;
color: $color-woot;
border: 0;
font-weight: $font-weight-bold;
font-size: $font-size-medium;
transition: all 0.3s var(--ease-in-cubic); transition: all 0.3s var(--ease-in-cubic);
margin-left: $space-smaller; @apply bg-transparent text-n-brand border-none border-0 font-semibold text-base ml-1 py-0 pl-0 pr-2.5 hover:brightness-75 hover:translate-x-1;
padding: 0 $space-one 0 0;
&:hover {
transform: translateX($space-smaller);
color: $color-primary-dark;
}
} }
.close-unread-button { .close-unread-button {
background: $color-background;
color: $color-light-gray;
border: 0;
font-weight: $font-weight-medium;
font-size: $font-size-mini;
transition: all 0.3s var(--ease-in-cubic); transition: all 0.3s var(--ease-in-cubic);
margin-bottom: $space-slab; @apply bg-n-slate-3 dark:bg-n-slate-12 text-n-slate-12 dark:text-n-slate-1 hover:brightness-95 border-none border-0 font-medium text-xxs rounded-2xl mb-3;
border-radius: $space-normal;
&:hover {
color: $color-body;
}
}
.is-background-light {
color: $color-body !important;
} }
} }
</style> </style>

View File

@@ -1,49 +0,0 @@
<script>
/**
* Thumbnail Component
* Src - source for round image
*/
export default {
name: 'UserAvatar',
props: {
src: {
type: String,
default: '',
},
size: {
type: String,
default: '',
},
},
computed: {
getBgImage() {
if (this.src) return { 'background-image': `url(${this.src})` };
return {};
},
},
};
</script>
<template>
<div class="user-avatar" :class="size" :style="getBgImage" />
</template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
@import 'widget/assets/scss/mixins.scss';
.user-avatar {
@include light-shadow;
background: url('widget/assets/images/defaultUser.png') center center
no-repeat;
background-size: cover;
border-radius: 50%;
height: 40px;
width: 40px;
&.small {
width: $space-medium;
height: $space-medium;
}
}
</style>

View File

@@ -165,12 +165,12 @@ export default {
</div> </div>
<div <div
v-if="isFailed" v-if="isFailed"
class="flex justify-end px-4 py-2 text-red-700 align-middle" class="flex justify-end px-4 py-2 text-n-ruby-9 align-middle"
> >
<button <button
v-if="!hasAttachments" v-if="!hasAttachments"
:title="$t('COMPONENTS.MESSAGE_BUBBLE.RETRY')" :title="$t('COMPONENTS.MESSAGE_BUBBLE.RETRY')"
class="inline-flex items-center justify-center ml-2" class="inline-flex items-center justify-center ltr:ml-2 rtl:mr-2"
@click="retrySendMessage" @click="retrySendMessage"
> >
<FluentIcon icon="arrow-clockwise" size="14" /> <FluentIcon icon="arrow-clockwise" size="14" />

View File

@@ -37,32 +37,24 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.chat-bubble.user::v-deep { .chat-bubble.user::v-deep {
p code { p code {
background-color: var(--w-600); @apply bg-n-alpha-2 dark:bg-n-alpha-1 text-white;
color: var(--white);
} }
pre { pre {
background-color: var(--w-800); @apply text-white bg-n-alpha-2 dark:bg-n-alpha-1;
border-color: var(--w-700);
color: var(--white);
code { code {
background-color: transparent; @apply bg-transparent text-white;
color: var(--white);
} }
} }
blockquote { blockquote {
border-left: $space-micro solid var(--w-400); @apply bg-transparent border-n-slate-7 ltr:border-l-2 rtl:border-r-2 border-solid;
background: var(--s-25);
border-color: var(--s-200);
p { p {
color: var(--s-800); @apply text-n-slate-5 dark:text-n-slate-12/90;
} }
} }
} }

View File

@@ -1,84 +0,0 @@
<script>
export default {
props: {
menuPlacement: {
type: String,
default: 'right',
validator: value => ['right', 'left'].indexOf(value) !== -1,
},
open: {
type: Boolean,
default: false,
},
toggleMenu: {
type: Function,
default: () => {},
},
},
data() {
return {
isOpen: false,
};
},
watch: {
open() {
this.isOpen = !this.isOpen;
},
},
mounted() {
document.addEventListener('keydown', this.onEscape);
},
unmounted() {
document.removeEventListener('keydown', this.onEscape);
},
methods: {
onEscape(e) {
if (e.key === 'Esc' || e.key === 'Escape') {
this.isOpen = false;
}
},
},
};
</script>
<template>
<div class="relative">
<button class="z-10 focus:outline-none select-none" @click="toggleMenu">
<slot name="button" />
</button>
<!-- to close when clicked on space around it-->
<button
v-if="isOpen"
tabindex="-1"
class="fixed inset-0 h-full w-full cursor-default focus:outline-none"
@click="toggleMenu"
/>
<!--dropdown menu-->
<transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-750 ease-in"
enter-class="opacity-0 scale-75"
enter-to-class="opacity-100 scale-100"
leave-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-75"
>
<div
v-if="isOpen"
class="menu-content absolute shadow-xl rounded-md border-solid border border-slate-100 mt-1 py-1 px-2 bg-white z-10"
:class="menuPlacement === 'right' ? 'right-0' : 'left-0'"
>
<slot name="content" />
</div>
</transition>
</div>
</template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.menu-content {
width: max-content;
}
</style>

View File

@@ -1,71 +0,0 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon },
props: {
text: {
type: String,
default: 'Default',
},
textClass: {
type: String,
default: 'text-sm leading-3',
},
icon: {
type: Boolean,
default: true,
},
iconName: {
type: String,
default: '',
},
iconSize: {
type: String,
default: '15',
},
iconClass: {
type: String,
default: 'text-black-900',
},
itemClass: {
type: String,
default:
'flex items-center p-3 cursor-pointer ml-0 border-b border-slate-100',
},
action: {
type: Function,
default: () => {},
},
},
};
</script>
<template>
<button class="menu-item" :class="[itemClass]" @click="action">
<FluentIcon
v-if="icon"
:icon="iconName"
:size="iconSize"
:class="iconClass"
/>
<span :class="[{ 'pl-3': icon }, textClass]">{{ text }}</span>
</button>
</template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables.scss';
.menu-item {
margin-left: $zero !important;
outline: none;
&:last-child {
border-bottom: none;
}
&:disabled {
cursor: not-allowed;
}
}
</style>

View File

@@ -103,17 +103,17 @@ export default {
<template> <template>
<div <div
class="w-full h-full bg-slate-25 dark:bg-slate-800" class="w-full h-full bg-n-slate-2 dark:bg-n-solid-1"
:class="{ 'overflow-auto': isOnHomeView }" :class="{ 'overflow-auto': isOnHomeView }"
@keydown.esc="closeWindow" @keydown.esc="closeWindow"
> >
<div class="relative flex flex-col h-full"> <div class="relative flex flex-col h-full">
<div <div
class="sticky top-0 z-40 transition-all header-wrap"
:class="{ :class="{
expanded: !isHeaderCollapsed, expanded: !isHeaderCollapsed,
collapsed: isHeaderCollapsed, collapsed: isHeaderCollapsed,
'custom-header-shadow': isHeaderCollapsed, 'shadow-[0_10px_15px_-16px_rgba(50,50,93,0.08),0_4px_6px_-8px_rgba(50,50,93,0.04)]':
isHeaderCollapsed,
...opacityClass, ...opacityClass,
}" }"
> >
@@ -140,29 +140,3 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
@import 'widget/assets/scss/variables';
@import 'widget/assets/scss/mixins';
.custom-header-shadow {
@include shadow-large;
}
.header-wrap {
flex-shrink: 0;
transition: max-height 100ms;
&.expanded {
max-height: 16rem;
}
&.collapsed {
max-height: 4.5rem;
}
@media only screen and (min-device-width: 320px) and (max-device-width: 667px) {
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
import ArticleListItem from './ArticleListItem.vue';
import { useMapGetter } from 'dashboard/composables/store';
const props = defineProps({
articles: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['view', 'viewAll']);
const widgetColor = useMapGetter('appConfig/getWidgetColor');
const articlesToDisplay = computed(() => props.articles.slice(0, 6));
const onArticleClick = link => {
emit('view', link);
};
</script>
<template>
<div class="flex flex-col gap-3">
<h3 class="font-medium text-n-slate-12">
{{ $t('PORTAL.POPULAR_ARTICLES') }}
</h3>
<div class="flex flex-col gap-4">
<ArticleListItem
v-for="article in articlesToDisplay"
:key="article.slug"
:link="article.link"
:title="article.title"
@select-article="onArticleClick"
/>
</div>
<div>
<button
class="font-medium tracking-wide inline-flex"
:style="{ color: widgetColor }"
@click="$emit('viewAll')"
>
<span>{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { computed, onMounted } from 'vue';
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
const store = useStore();
const router = useRouter();
const i18n = useI18n();
const { prefersDarkMode } = useDarkMode();
const portal = computed(() => window.chatwootWebChannel.portal);
const popularArticles = useMapGetter('article/popularArticles');
const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
const {
allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
});
const fetchArticles = () => {
if (portal.value && !popularArticles.value.length) {
store.dispatch('article/fetch', {
slug: portal.value.slug,
locale: locale.value,
});
}
};
const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
});
// Combine link with query parameters
const linkToOpen = `${link}?${params.toString()}`;
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
};
const viewAllArticles = () => {
const {
portal: { slug },
} = window.chatwootWebChannel;
openArticleInArticleViewer(`/hc/${slug}/${locale.value}`);
};
const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
!!popularArticles.value.length
);
onMounted(() => fetchArticles());
</script>
<template>
<div
v-if="portal && (articleUiFlags.isFetching || !!popularArticles.length)"
class="w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
>
<ArticleBlock
v-if="hasArticles"
:articles="popularArticles"
@view="openArticleInArticleViewer"
@view-all="viewAllArticles"
/>
<ArticleCardSkeletonLoader v-if="articleUiFlags.isFetching" />
</div>
<div v-else class="hidden" />
</template>

View File

@@ -0,0 +1,32 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
link: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectArticle']);
const onClick = () => {
emit('selectArticle', props.link);
};
</script>
<template>
<div
class="flex items-center justify-between rounded cursor-pointer text-n-slate-11 hover:text-n-slate-12 gap-2"
role="button"
@click="onClick"
>
<button
class="underline-offset-2 leading-6 ltr:text-left rtl:text-right text-base"
>
{{ title }}
</button>
<span class="i-lucide-chevron-right text-base shrink-0" />
</div>
</template>

View File

@@ -0,0 +1,15 @@
<template>
<div class="py-4 space-y-4">
<div class="space-y-2 animate-pulse">
<div class="h-6 bg-n-slate-5 dark:bg-n-alpha-black1 rounded w-2/5" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
</div>
<div class="space-y-2 animate-pulse">
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded w-1/5" />
</div>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script> <script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
components: { components: {
@@ -15,8 +14,7 @@ export default {
}, },
setup() { setup() {
const { truncateMessage } = useMessageFormatter(); const { truncateMessage } = useMessageFormatter();
const { getThemeClass } = useDarkMode(); return { truncateMessage };
return { getThemeClass, truncateMessage };
}, },
}; };
</script> </script>
@@ -25,52 +23,29 @@ export default {
<template> <template>
<div <div
v-if="!!items.length" v-if="!!items.length"
class="chat-bubble agent" class="chat-bubble agent bg-n-background dark:bg-n-solid-3"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
> >
<div v-for="item in items" :key="item.link" class="article-item"> <div
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow"> v-for="item in items"
<span class="title flex items-center text-black-900 font-medium"> :key="item.link"
<FluentIcon class="border-b border-solid border-n-weak text-sm py-2 px-0 last:border-b-0"
icon="link" >
class="mr-1" <a
:class="getThemeClass('text-black-900', 'dark:text-slate-50')" :href="item.link"
/> target="_blank"
<span :class="getThemeClass('text-slate-900', 'dark:text-slate-50')"> rel="noopener noreferrer nofollow"
class="text-n-slate-12 no-underline"
>
<span class="flex items-center text-black-900 font-medium">
<FluentIcon icon="link" class="ltr:mr-1 rtl:ml-1 text-n-slate-12" />
<span class="text-n-slate-12">
{{ item.title }} {{ item.title }}
</span> </span>
</span> </span>
<span <span class="block mt-1 text-n-slate-12">
class="description"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
>
{{ truncateMessage(item.description) }} {{ truncateMessage(item.description) }}
</span> </span>
</a> </a>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.article-item {
border-bottom: 1px solid $color-border;
font-size: $font-size-default;
padding: $space-small 0;
a {
color: $color-body;
text-decoration: none;
}
.description {
display: block;
margin-top: $space-smaller;
}
&:last-child {
border-bottom: 0;
}
}
</style>

View File

@@ -6,7 +6,6 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
export default { export default {
components: { components: {
@@ -24,8 +23,7 @@ export default {
}, },
}, },
setup() { setup() {
const { getThemeClass } = useDarkMode(); return { v$: useVuelidate() };
return { v$: useVuelidate(), getThemeClass };
}, },
data() { data() {
return { return {
@@ -46,16 +44,6 @@ export default {
this.messageContentAttributes.submitted_email this.messageContentAttributes.submitted_email
); );
}, },
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}
${this.getThemeClass('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.v$.email.$error
? `${this.inputColor} error`
: `${this.inputColor}`;
},
}, },
validations: { validations: {
email: { email: {
@@ -88,14 +76,14 @@ export default {
<div> <div>
<form <form
v-if="!hasSubmitted" v-if="!hasSubmitted"
class="email-input-group" class="email-input-group h-10 flex my-2 mx-0 min-w-[200px]"
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<input <input
v-model="email" v-model="email"
class="form-input" type="email"
:placeholder="$t('EMAIL_PLACEHOLDER')" :placeholder="$t('EMAIL_PLACEHOLDER')"
:class="inputHasError" :class="{ error: v$.email.$error }"
@input="v$.email.$touch" @input="v$.email.$touch"
@keydown.enter="onSubmit" @keydown.enter="onSubmit"
/> />
@@ -116,34 +104,21 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.email-input-group { .email-input-group {
display: flex;
margin: $space-small 0;
min-width: 200px;
input { input {
border-bottom-right-radius: 0; @apply dark:bg-n-alpha-black1 rtl:rounded-tl-[0] ltr:rounded-tr-[0] rtl:rounded-bl-[0] ltr:rounded-br-[0] p-2.5 w-full focus:ring-0 focus:outline-n-brand;
border-top-right-radius: 0;
padding: $space-one;
width: 100%;
&::placeholder { &::placeholder {
color: $color-light-gray; @apply text-n-slate-10;
} }
&.error { &.error {
border-color: $color-error; @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
} }
} }
.button { .button {
border-bottom-left-radius: 0; @apply rtl:rounded-tr-[0] ltr:rounded-tl-[0] rtl:rounded-br-[0] ltr:rounded-bl-[0] rounded-lg h-auto ltr:-ml-px rtl:-mr-px text-xl;
border-top-left-radius: 0;
font-size: $font-size-large;
height: auto;
margin-left: -1px;
.spinner { .spinner {
display: block; display: block;

View File

@@ -62,7 +62,7 @@ export default {
}" }"
@click="joinTheCall" @click="joinTheCall"
> >
<FluentIcon icon="video-add" class="mr-2" /> <FluentIcon icon="video-add" class="rtl:ml-2 ltr:mr-2" />
{{ $t('INTEGRATIONS.DYTE.CLICK_HERE_TO_JOIN') }} {{ $t('INTEGRATIONS.DYTE.CLICK_HERE_TO_JOIN') }}
</button> </button>
<div v-if="dyteAuthToken" class="video-call--container"> <div v-if="dyteAuthToken" class="video-call--container">
@@ -81,8 +81,6 @@ export default {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'widget/assets/scss/variables.scss';
.video-call--container { .video-call--container {
position: fixed; position: fixed;
top: 72px; top: 72px;
@@ -101,15 +99,10 @@ export default {
} }
.join-call-button { .join-call-button {
margin: $space-small 0; @apply flex items-center my-2 rounded-lg;
border-radius: 4px;
display: flex;
align-items: center;
} }
.leave-room-button { .leave-room-button {
position: absolute; @apply absolute top-0 ltr:right-2 rtl:left-2 px-1 rounded-md;
top: 0;
right: $space-small;
} }
</style> </style>

View File

@@ -14,11 +14,10 @@ describe('useDarkMode', () => {
vi.mocked(useMapGetter).mockReturnValue(mockDarkMode); vi.mocked(useMapGetter).mockReturnValue(mockDarkMode);
}); });
it('returns darkMode, prefersDarkMode, and getThemeClass', () => { it('returns darkMode, prefersDarkMode', () => {
const result = useDarkMode(); const result = useDarkMode();
expect(result).toHaveProperty('darkMode'); expect(result).toHaveProperty('darkMode');
expect(result).toHaveProperty('prefersDarkMode'); expect(result).toHaveProperty('prefersDarkMode');
expect(result).toHaveProperty('getThemeClass');
}); });
describe('prefersDarkMode', () => { describe('prefersDarkMode', () => {
@@ -47,25 +46,4 @@ describe('useDarkMode', () => {
expect(prefersDarkMode.value).toBe(false); expect(prefersDarkMode.value).toBe(false);
}); });
}); });
describe('getThemeClass', () => {
it('returns light class when darkMode is light', () => {
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('light-class');
});
it('returns dark class when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('dark-class');
});
it('returns both classes when darkMode is auto', () => {
mockDarkMode.value = 'auto';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe(
'light-class dark-class'
);
});
});
}); });

View File

@@ -10,11 +10,6 @@ const getSystemPreference = () =>
const calculatePrefersDarkMode = (mode, systemPreference) => const calculatePrefersDarkMode = (mode, systemPreference) =>
isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode); isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode);
const calculateThemeClass = (mode, light, dark) => {
if (isDarkModeAuto(mode)) return `${light} ${dark}`;
return isDarkMode(mode) ? dark : light;
};
/** /**
* Composable for handling dark mode. * Composable for handling dark mode.
* @returns {Object} An object containing computed properties and methods for dark mode. * @returns {Object} An object containing computed properties and methods for dark mode.
@@ -28,12 +23,8 @@ export function useDarkMode() {
calculatePrefersDarkMode(darkMode.value, systemPreference.value) calculatePrefersDarkMode(darkMode.value, systemPreference.value)
); );
const getThemeClass = (light, dark) =>
calculateThemeClass(darkMode.value, light, dark);
return { return {
darkMode, darkMode,
prefersDarkMode, prefersDarkMode,
getThemeClass,
}; };
} }

View File

@@ -1,5 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import ViewWithHeader from './components/layouts/ViewWithHeader.vue'; import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
import UnreadMessages from './views/UnreadMessages.vue';
import Campaigns from './views/Campaigns.vue';
import Home from './views/Home.vue';
import PreChatForm from './views/PreChatForm.vue';
import Messages from './views/Messages.vue';
import ArticleViewer from './views/ArticleViewer.vue';
import store from './store'; import store from './store';
const router = createRouter({ const router = createRouter({
@@ -8,12 +14,12 @@ const router = createRouter({
{ {
path: '/unread-messages', path: '/unread-messages',
name: 'unread-messages', name: 'unread-messages',
component: () => import('./views/UnreadMessages.vue'), component: UnreadMessages,
}, },
{ {
path: '/campaigns', path: '/campaigns',
name: 'campaigns', name: 'campaigns',
component: () => import('./views/Campaigns.vue'), component: Campaigns,
}, },
{ {
path: '/', path: '/',
@@ -22,22 +28,22 @@ const router = createRouter({
{ {
path: '', path: '',
name: 'home', name: 'home',
component: () => import('./views/Home.vue'), component: Home,
}, },
{ {
path: '/prechat-form', path: '/prechat-form',
name: 'prechat-form', name: 'prechat-form',
component: () => import('./views/PreChatForm.vue'), component: PreChatForm,
}, },
{ {
path: '/messages', path: '/messages',
name: 'messages', name: 'messages',
component: () => import('./views/Messages.vue'), component: Messages,
}, },
{ {
path: '/article', path: '/article',
name: 'article-viewer', name: 'article-viewer',
component: () => import('./views/ArticleViewer.vue'), component: ArticleViewer,
}, },
], ],
}, },

View File

@@ -1,16 +1,24 @@
<script> <script>
import IframeLoader from 'shared/components/IframeLoader.vue'; import IframeLoader from 'shared/components/IframeLoader.vue';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default { export default {
name: 'ArticleViewer', name: 'ArticleViewer',
components: { components: {
IframeLoader, IframeLoader,
}, },
computed: {
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
},
}; };
</script> </script>
<template> <template>
<div class="bg-white h-full"> <div class="bg-white h-full">
<IframeLoader :url="$route.query.link" /> <IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
</div> </div>
</template> </template>

View File

@@ -1,68 +1,22 @@
<script> <script>
import TeamAvailability from 'widget/components/TeamAvailability.vue'; import TeamAvailability from 'widget/components/TeamAvailability.vue';
import ArticleHero from 'widget/components/ArticleHero.vue';
import ArticleCardSkeletonLoader from 'widget/components/ArticleCardSkeletonLoader.vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useDarkMode } from 'widget/composables/useDarkMode';
import routerMixin from 'widget/mixins/routerMixin'; import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin'; import configMixin from 'widget/mixins/configMixin';
import ArticleContainer from '../components/pageComponents/Home/Article/ArticleContainer.vue';
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
ArticleHero, ArticleContainer,
TeamAvailability, TeamAvailability,
ArticleCardSkeletonLoader,
}, },
mixins: [configMixin, routerMixin], mixins: [configMixin, routerMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode };
},
computed: { computed: {
...mapGetters({ ...mapGetters({
availableAgents: 'agent/availableAgents', availableAgents: 'agent/availableAgents',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
unreadMessageCount: 'conversation/getUnreadMessageCount', unreadMessageCount: 'conversation/getUnreadMessageCount',
popularArticles: 'article/popularArticles',
articleUiFlags: 'article/uiFlags',
}), }),
widgetLocale() {
return this.$i18n.locale || 'en';
},
portal() {
return window.chatwootWebChannel.portal;
},
showArticles() {
return (
this.portal &&
!this.articleUiFlags.isFetching &&
this.popularArticles.length
);
},
defaultLocale() {
const widgetLocale = this.widgetLocale;
const { allowed_locales: allowedLocales, default_locale: defaultLocale } =
this.portal.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(widgetLocale)) {
return widgetLocale;
}
return defaultLocale;
},
},
mounted() {
if (this.portal && this.popularArticles.length === 0) {
const locale = this.defaultLocale;
this.$store.dispatch('article/fetch', {
slug: this.portal.slug,
locale,
});
}
}, },
methods: { methods: {
startConversation() { startConversation() {
@@ -71,59 +25,19 @@ export default {
} }
return this.replaceRoute('messages'); return this.replaceRoute('messages');
}, },
openArticleInArticleViewer(link) {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: this.prefersDarkMode ? 'dark' : 'light',
});
const linkToOpen = `${link}?${params.toString()}`;
this.$router.push({
name: 'article-viewer',
query: { link: linkToOpen },
});
},
viewAllArticles() {
const locale = this.defaultLocale;
const {
portal: { slug },
} = window.chatwootWebChannel;
this.openArticleInArticleViewer(`/hc/${slug}/${locale}`);
},
}, },
}; };
</script> </script>
<template> <template>
<div <div class="z-50 flex flex-col justify-end flex-1 w-full p-4 gap-4">
class="z-50 flex flex-col flex-1 w-full rounded-md" <TeamAvailability
:class="{ 'pb-2': showArticles, 'justify-end': !showArticles }" :available-agents="availableAgents"
> :has-conversation="!!conversationSize"
<div class="w-full px-4 pt-4"> :unread-count="unreadMessageCount"
<TeamAvailability @start-conversation="startConversation"
:available-agents="availableAgents" />
:has-conversation="!!conversationSize"
:unread-count="unreadMessageCount" <ArticleContainer />
@start-conversation="startConversation"
/>
</div>
<div v-if="showArticles" class="w-full px-4 py-2">
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
<ArticleHero
v-if="
!articleUiFlags.isFetching &&
!articleUiFlags.isError &&
popularArticles.length
"
:articles="popularArticles"
@view="openArticleInArticleViewer"
@view-all="viewAllArticles"
/>
</div>
</div>
<div v-if="articleUiFlags.isFetching" class="w-full px-4 py-2">
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
<ArticleCardSkeletonLoader />
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -19,7 +19,7 @@ export default {
<template> <template>
<div <div
class="flex flex-col flex-1 overflow-hidden rounded-b-lg bg-slate-25 dark:bg-slate-800" class="flex flex-col flex-1 overflow-hidden rounded-b-lg bg-n-slate-2 dark:bg-n-solid-1"
> >
<div class="flex flex-1 overflow-auto"> <div class="flex flex-1 overflow-auto">
<ConversationWrap :grouped-messages="groupedMessages" /> <ConversationWrap :grouped-messages="groupedMessages" />