Merge branch 'develop' into dependabot/npm_and_yarn/express-4.18.2

This commit is contained in:
Pranav Raj S 2022-12-14 15:21:34 -08:00 committed by GitHub
commit 9ecacd912b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 2329 additions and 394 deletions

View file

@ -398,7 +398,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
loofah (2.18.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -427,14 +427,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.9)
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.9-arm64-darwin)
nokogiri (1.13.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.9-x86_64-darwin)
nokogiri (1.13.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.9-x86_64-linux)
nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@ -459,7 +459,7 @@ GEM
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
racc (1.6.1)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@ -488,8 +488,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
rails-html-sanitizer (1.4.4)
loofah (~> 2.19, >= 2.19.1)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)

View file

@ -46,6 +46,7 @@ class Messages::Messenger::MessageBuilder
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
@ -62,6 +63,7 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
@ -74,6 +76,7 @@ class Messages::Messenger::MessageBuilder
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}

View file

@ -24,7 +24,6 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',

View file

@ -0,0 +1,6 @@
/* global axios */
import wootConstants from 'dashboard/constants';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);
};

View file

@ -345,6 +345,9 @@ export default {
};
},
pageTitle() {
if (this.hasAppliedFilters) {
return this.$t('CHAT_LIST.TAB_HEADING');
}
if (this.inbox.name) {
return this.inbox.name;
}
@ -439,9 +442,6 @@ export default {
},
methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');

View file

@ -112,6 +112,7 @@ $label-badge-size: var(--space-slab);
padding: var(--space-smaller) var(--space-smaller);
margin: var(--space-smaller) 0;
text-align: left;
line-height: 1.2;
&:hover {
background: var(--s-25);
@ -135,8 +136,6 @@ $label-badge-size: var(--space-slab);
.menu-label {
flex-grow: 1;
display: inline-flex;
align-items: center;
}
.inbox-icon {

View file

@ -61,6 +61,7 @@ export default {
}
.colorpicker--selected {
border: 1px solid var(--color-border-light);
border-radius: $space-smaller;
cursor: pointer;
height: $space-large;

View file

@ -46,11 +46,16 @@ export default {
return {
conversation: this.currentChat,
contact: this.$store.getters['contacts/getContact'](this.contactId),
currentAgent: this.currentAgent,
};
},
contactId() {
return this.currentChat?.meta?.sender?.id;
},
currentAgent() {
const { id, name, email } = this.$store.getters.getCurrentUser;
return { id, name, email };
},
},
mounted() {

View file

@ -47,6 +47,9 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
@ -268,6 +271,7 @@ export default {
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
return false;
},
@ -297,6 +301,7 @@ export default {
this.emitOnChange();
tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},

View file

@ -161,6 +161,9 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@ -698,6 +701,7 @@ export default {
},
replaceText(message) {
setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message;
}, 100);
},

View file

@ -54,19 +54,6 @@
size="16"
/>
</button>
<a
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
:href="linkToStory"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
icon="open"
class="action--icon cursor-pointer"
size="16"
/>
</a>
<a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"

View file

@ -22,5 +22,6 @@ export default {
EXPANDED: 'expanded',
},
DOCS_URL: '//www.chatwoot.com/docs/product/',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
};
export const DEFAULT_REDIRECT_URL = '/app/';

View file

@ -0,0 +1,9 @@
export const EXECUTED_A_MACRO = 'Executed a macro';
export const SENT_MESSAGE = 'Sent a message';
export const SENT_PRIVATE_NOTE = 'Sent a private note';
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
export const USED_MENTIONS = 'Used mentions';
export const MERGED_CONTACTS = 'Used merge contact option';
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox';

View file

@ -0,0 +1,67 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper {
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
async init() {
if (!this.analyticsToken) {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
});
this.analytics = analytics;
}
identify(user) {
if (!this.analytics) {
return;
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
name: currentAccount.name,
});
}
}
track(eventName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
}
page(params) {
if (!this.analytics) {
return;
}
this.analytics.page(params);
}
}
export * as ANALYTICS_EVENTS from './events';
export default new AnalyticsHelper(window.analyticsConfig);

View file

@ -17,13 +17,22 @@ const formatArray = params => {
return params;
};
const generatePayloadForObject = item => {
if (item.action_params.id) {
item.action_params = [item.action_params.id];
} else {
item.action_params = [item.action_params];
}
return item.action_params;
};
const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => {
if (Array.isArray(item.action_params)) {
item.action_params = formatArray(item.action_params);
} else if (typeof item.action_params === 'object') {
item.action_params = [item.action_params.id];
item.action_params = generatePayloadForObject(item);
} else if (!item.action_params) {
item.action_params = [];
} else {

View file

@ -1,4 +1,4 @@
import posthog from 'posthog-js';
import AnalyticsHelper from './AnalyticsHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@ -8,16 +8,9 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
if (window.analyticsConfig) {
posthog.identify(user.id, { name: user.name, email: user.email });
}
});
window.bus.$on(ANALYTICS_RESET, () => {
if (window.analyticsConfig) {
posthog.reset();
}
AnalyticsHelper.identify(user);
});
window.bus.$on(ANALYTICS_RESET, () => {});
};
export const initializeChatwootEvents = () => {

View file

@ -134,7 +134,7 @@
"PHONE_NUMBER": {
"LABEL": "Phone number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
"API_CALLBACK": {
"TITLE": "Callback URL",
@ -185,7 +185,7 @@
"PHONE_NUMBER": {
"LABEL": "Phone number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
"SUBMIT_BUTTON": "Create Bandwidth Channel",
"API": {
@ -214,7 +214,7 @@
"PHONE_NUMBER": {
"LABEL": "Phone number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
"PHONE_NUMBER_ID": {
"LABEL": "Phone number ID",

View file

@ -99,11 +99,7 @@
},
"AVAILABILITY": {
"LABEL": "Availability",
"STATUSES_LIST": [
"Online",
"Busy",
"Offline"
]
"STATUSES_LIST": ["Online", "Busy", "Offline"]
},
"EMAIL": {
"LABEL": "Your email address",
@ -257,7 +253,7 @@
},
"FORM": {
"NAME": {
"LABEL": "Account Name",
"LABEL": "Company Name",
"PLACEHOLDER": "Wayne Enterprises"
},
"SUBMIT": "Submit"

View file

@ -2,11 +2,13 @@
"REGISTER": {
"TRY_WOOT": "Register an account",
"TITLE": "Register",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
"TERMS_ACCEPT": "By signing up, you agree to our <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"ACCOUNT_NAME": {
"LABEL": "Account name",
"PLACEHOLDER": "Enter an account name. eg: Wayne Enterprises",
"ERROR": "Account name is too short"
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
"ERROR": "Company name is too short"
},
"FULL_NAME": {
"LABEL": "Full name",
@ -16,7 +18,7 @@
"EMAIL": {
"LABEL": "Work email",
"PLACEHOLDER": "Enter your work email address. eg: bruce@wayne.enterprises",
"ERROR": "Email address is invalid"
"ERROR": "Please enter a valid work email address"
},
"PASSWORD": {
"LABEL": "Password",

View file

@ -24,6 +24,9 @@ import MergeContact from 'dashboard/modules/contact/components/MergeContact';
import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
export default {
components: { MergeContact },
@ -72,6 +75,7 @@ export default {
}
},
async onMergeContacts(childContactId) {
AnalyticsHelper.track(ANALYTICS_EVENTS.MERGED_CONTACTS);
try {
await this.$store.dispatch('contacts/merge', {
childId: childContactId,

View file

@ -72,6 +72,9 @@ import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
export default {
components: {
@ -127,6 +130,7 @@ export default {
this.$emit('toggle', false);
},
showCannedResponseModal() {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true;
},
},

View file

@ -1,277 +1,133 @@
<template>
<div class="medium-10 column signup">
<div class="text-center medium-12 signup--hero">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="hero--logo"
/>
<h2 class="hero--title">
{{ $t('REGISTER.TRY_WOOT') }}
</h2>
</div>
<div class="row align-center">
<div class="small-12 medium-6 large-5 column">
<form class="signup--box login-box" @submit.prevent="submit">
<woot-input
v-model="credentials.fullName"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:error="
$v.credentials.fullName.$error
? $t('REGISTER.FULL_NAME.ERROR')
: ''
"
@blur="$v.credentials.fullName.$touch"
/>
<woot-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:error="
$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''
"
@blur="$v.credentials.email.$touch"
/>
<woot-input
v-model="credentials.accountName"
:class="{ error: $v.credentials.accountName.$error }"
:label="$t('REGISTER.ACCOUNT_NAME.LABEL')"
:placeholder="$t('REGISTER.ACCOUNT_NAME.PLACEHOLDER')"
:error="
$v.credentials.accountName.$error
? $t('REGISTER.ACCOUNT_NAME.ERROR')
: ''
"
@blur="$v.credentials.accountName.$touch"
/>
<woot-input
v-model.trim="credentials.password"
type="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified"
<div class="h-full w-full">
<div v-show="!isLoading" class="row h-full">
<div
:class="
`${showTestimonials ? 'large-6' : 'large-12'} signup-form--container`
"
>
<div class="signup-form--content">
<div class="signup--hero">
<img
:src="globalConfig.logo"
:alt="globalConfig.installationName"
class="hero--logo"
/>
<span
v-if="!hasAValidCaptcha && didCaptchaReset"
class="captcha-error"
>
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
<h2 class="hero--title">
{{ $t('REGISTER.TRY_WOOT') }}
</h2>
</div>
<signup-form />
<div class="auth-screen--footer">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<router-link to="/app/login">
{{
useInstallationName(
$t('LOGIN.TITLE'),
globalConfig.installationName
)
}}
</router-link>
</div>
<woot-submit-button
:disabled="isSignupInProgress || !hasAValidCaptcha"
:button-text="$t('REGISTER.SUBMIT')"
:loading="isSignupInProgress"
button-class="large expanded"
/>
<p v-dompurify-html="termsLink" class="accept--terms" />
</form>
<div class="column text-center sigin--footer">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<router-link to="/app/login">
{{
useInstallationName(
$t('LOGIN.TITLE'),
globalConfig.installationName
)
}}
</router-link>
</div>
</div>
<testimonials
v-if="isAChatwootInstance"
class="medium-6 testimonial--container"
@resize-containers="resizeContainers"
/>
</div>
<div v-show="isLoading" class="spinner--container">
<spinner size="" />
</div>
</div>
</template>
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import Auth from '../../api/auth';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from '../../constants';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import { isValidPassword } from 'shared/helpers/Validators';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
VueHcaptcha,
SignupForm,
Spinner,
Testimonials,
},
mixins: [globalConfigMixin, alertMixin],
mixins: [globalConfigMixin],
data() {
return {
credentials: {
accountName: '',
fullName: '',
email: '',
password: '',
hCaptchaClientResponse: '',
},
didCaptchaReset: false,
isSignupInProgress: false,
error: '',
};
},
validations: {
credentials: {
accountName: {
required,
minLength: minLength(2),
},
fullName: {
required,
minLength: minLength(2),
},
email: {
required,
email,
},
password: {
required,
isValidPassword,
minLength: minLength(6),
},
},
return { showTestimonials: false, isLoading: false };
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
termsLink() {
return this.$t('REGISTER.TERMS_ACCEPT')
.replace('https://www.chatwoot.com/terms', this.globalConfig.termsURL)
.replace(
'https://www.chatwoot.com/privacy-policy',
this.globalConfig.privacyURL
);
},
hasAValidCaptcha() {
if (this.globalConfig.hCaptchaSiteKey) {
return !!this.credentials.hCaptchaClientResponse;
}
return true;
},
passwordErrorText() {
const { password } = this.$v.credentials;
if (!password.$error) {
return '';
}
if (!password.minLength) {
return this.$t('REGISTER.PASSWORD.ERROR');
}
if (!password.isValidPassword) {
return this.$t('REGISTER.PASSWORD.IS_INVALID_PASSWORD');
}
return '';
isAChatwootInstance() {
return this.globalConfig.installationName === 'Chatwoot';
},
},
beforeMount() {
this.isLoading = this.isAChatwootInstance;
},
methods: {
async submit() {
this.$v.$touch();
if (this.$v.$invalid) {
this.resetCaptcha();
return;
}
this.isSignupInProgress = true;
try {
const response = await Auth.register(this.credentials);
if (response.status === 200) {
window.location = DEFAULT_REDIRECT_URL;
}
} catch (error) {
let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE');
if (error.response && error.response.data.message) {
this.resetCaptcha();
errorMessage = error.response.data.message;
}
this.showAlert(errorMessage);
} finally {
this.isSignupInProgress = false;
}
},
onRecaptchaVerified(token) {
this.credentials.hCaptchaClientResponse = token;
this.didCaptchaReset = false;
},
resetCaptcha() {
if (!this.globalConfig.hCaptchaSiteKey) {
return;
}
this.$refs.hCaptcha.reset();
this.credentials.hCaptchaClientResponse = '';
this.didCaptchaReset = true;
resizeContainers(hasTestimonials) {
this.showTestimonials = hasTestimonials;
this.isLoading = false;
},
},
};
</script>
<style scoped lang="scss">
.signup {
.signup--hero {
margin-bottom: var(--space-larger);
.signup-form--container {
display: flex;
align-items: center;
height: 100%;
min-height: 640px;
overflow: auto;
justify-content: center;
}
.hero--logo {
width: 180px;
}
.signup-form--content {
padding: var(--space-jumbo);
max-width: 600px;
width: 100%;
}
.hero--title {
margin-top: var(--space-large);
font-weight: var(--font-weight-light);
}
.signup--hero {
margin-bottom: var(--space-normal);
.hero--logo {
width: 160px;
}
.signup--box {
padding: var(--space-large);
label {
font-size: var(--font-size-default);
color: var(--b-600);
input {
padding: var(--space-slab);
height: var(--space-larger);
font-size: var(--font-size-default);
}
}
}
.sigin--footer {
padding: var(--space-medium);
font-size: var(--font-size-default);
> a {
font-weight: var(--font-weight-bold);
}
}
.accept--terms {
font-size: var(--font-size-small);
text-align: center;
margin: var(--space-normal) 0 0 0;
}
.h-captcha--box {
margin-bottom: var(--space-one);
.captcha-error {
color: var(--r-400);
font-size: var(--font-size-small);
}
&::v-deep .error {
iframe {
border: 1px solid var(--r-500);
border-radius: var(--border-radius-normal);
}
}
.hero--title {
margin-top: var(--space-medium);
font-weight: var(--font-weight-light);
}
}
.auth-screen--footer {
font-size: var(--font-size-small);
}
@media screen and (max-width: 1200px) {
.testimonial--container {
display: none;
}
.signup-form--container {
width: 100%;
flex: 0 0 100%;
max-width: 100%;
}
.signup-form--content {
margin: 0 auto;
}
}
.spinner--container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View file

@ -1,12 +1,19 @@
import Auth from './Auth';
import Confirmation from './Confirmation';
import Signup from './Signup';
import PasswordEdit from './PasswordEdit';
import ResetPassword from './ResetPassword';
import { frontendURL } from '../../helper/URLHelper';
const Signup = () => import('./Signup');
export default {
routes: [
{
path: frontendURL('auth/signup'),
name: 'auth_signup',
component: Signup,
meta: { requireSignupEnabled: true },
},
{
path: frontendURL('auth'),
name: 'auth',
@ -32,12 +39,6 @@ export default {
redirectUrl: route.query.route_url,
}),
},
{
path: 'signup',
name: 'auth_signup',
component: Signup,
meta: { requireSignupEnabled: true },
},
{
path: 'reset/password',
name: 'auth_reset_password',

View file

@ -0,0 +1,93 @@
<template>
<label class="auth-input--wrap">
<div class="label-wrap">
<fluent-icon v-if="iconName" :icon="iconName" size="16" />
<span v-if="label">{{ label }}</span>
</div>
<div class="input--wrap">
<input
class="auth-input"
:value="value"
:type="type"
:placeholder="placeholder"
:readonly="readonly"
@input="onChange"
@blur="onBlur"
/>
<p v-if="helpText" class="help-text" />
<span v-if="error" class="message">
{{ error }}
</span>
</div>
</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
placeholder: {
type: String,
default: '',
},
iconName: {
type: String,
default: '',
},
helpText: {
type: String,
default: '',
},
error: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
},
methods: {
onChange(e) {
this.$emit('input', e.target.value);
},
onBlur(e) {
this.$emit('blur', e.target.value);
},
},
};
</script>
<style lang="scss" scoped>
.auth-input--wrap {
.label-wrap {
display: flex;
align-items: center;
color: var(--s-900);
margin-bottom: var(--space-smaller);
span {
margin-left: var(--space-smaller);
font-size: var(--font-size-small);
}
}
.auth-input {
font-size: var(--font-size-small) !important;
height: 4rem !important;
padding: var(--space-small) !important;
width: 100% !important;
background: var(--s-50) !important;
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<woot-button
size="expanded"
color-scheme="primary"
class-names="submit--button"
:is-disabled="isDisabled"
:is-loading="isLoading"
@click="onClick"
>
{{ label }}
<fluent-icon :icon="icon" size="18" />
</woot-button>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
isDisabled: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
},
methods: {
onClick() {
this.$emit('click');
},
},
};
</script>
<style lang="scss" scoped>
.submit--button {
align-items: center;
display: flex;
margin: 0 0 var(--space-normal) 0;
&::v-deep .button__content {
align-items: center;
display: flex;
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,217 @@
<template>
<form @submit.prevent="submit">
<div class="input-wrap">
<auth-input
v-model="credentials.fullName"
:class="{ error: $v.credentials.fullName.$error }"
:label="$t('REGISTER.FULL_NAME.LABEL')"
icon-name="person"
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
:error="
$v.credentials.fullName.$error ? $t('REGISTER.FULL_NAME.ERROR') : ''
"
@blur="$v.credentials.fullName.$touch"
/>
<auth-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
icon-name="mail"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:error="$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''"
@blur="$v.credentials.email.$touch"
/>
<auth-input
v-model="credentials.accountName"
:class="{ error: $v.credentials.accountName.$error }"
icon-name="building-bank"
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
:error="
$v.credentials.accountName.$error
? $t('REGISTER.COMPANY_NAME.ERROR')
: ''
"
@blur="$v.credentials.accountName.$touch"
/>
<auth-input
v-model.trim="credentials.password"
type="password"
:class="{ error: $v.credentials.password.$error }"
icon-name="lock-closed"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error="passwordErrorText"
@blur="$v.credentials.password.$touch"
/>
</div>
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified"
/>
<span v-if="!hasAValidCaptcha && didCaptchaReset" class="captcha-error">
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
</div>
<auth-submit-button
:label="$t('REGISTER.SUBMIT')"
:is-disabled="isSignupInProgress || !hasAValidCaptcha"
:is-loading="isSignupInProgress"
icon="arrow-chevron-right"
/>
<p v-dompurify-html="termsLink" class="accept--terms" />
</form>
</template>
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import Auth from '../../../../api/auth';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from '../../../../constants';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import AuthInput from '../AuthInput.vue';
import AuthSubmitButton from '../AuthSubmitButton.vue';
import { isValidPassword } from 'shared/helpers/Validators';
var CompanyEmailValidator = require('company-email-validator');
export default {
components: {
AuthInput,
AuthSubmitButton,
VueHcaptcha,
},
mixins: [globalConfigMixin, alertMixin],
data() {
return {
credentials: {
accountName: '',
fullName: '',
email: '',
password: '',
hCaptchaClientResponse: '',
},
didCaptchaReset: false,
isSignupInProgress: false,
error: '',
};
},
validations: {
credentials: {
accountName: {
required,
minLength: minLength(2),
},
fullName: {
required,
minLength: minLength(2),
},
email: {
required,
email,
businessEmailValidator(value) {
return CompanyEmailValidator.isCompanyEmail(value);
},
},
password: {
required,
isValidPassword,
minLength: minLength(6),
},
},
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
termsLink() {
return this.$t('REGISTER.TERMS_ACCEPT')
.replace('https://www.chatwoot.com/terms', this.globalConfig.termsURL)
.replace(
'https://www.chatwoot.com/privacy-policy',
this.globalConfig.privacyURL
);
},
hasAValidCaptcha() {
if (this.globalConfig.hCaptchaSiteKey) {
return !!this.credentials.hCaptchaClientResponse;
}
return true;
},
passwordErrorText() {
const { password } = this.$v.credentials;
if (!password.$error) {
return '';
}
if (!password.minLength) {
return this.$t('REGISTER.PASSWORD.ERROR');
}
if (!password.isValidPassword) {
return this.$t('REGISTER.PASSWORD.IS_INVALID_PASSWORD');
}
return '';
},
},
methods: {
async submit() {
this.$v.$touch();
if (this.$v.$invalid) {
this.resetCaptcha();
return;
}
this.isSignupInProgress = true;
try {
const response = await Auth.register(this.credentials);
if (response.status === 200) {
window.location = DEFAULT_REDIRECT_URL;
}
} catch (error) {
let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE');
if (error.response && error.response.data.message) {
this.resetCaptcha();
errorMessage = error.response.data.message;
}
this.showAlert(errorMessage);
} finally {
this.isSignupInProgress = false;
}
},
onRecaptchaVerified(token) {
this.credentials.hCaptchaClientResponse = token;
this.didCaptchaReset = false;
},
resetCaptcha() {
if (!this.globalConfig.hCaptchaSiteKey) {
return;
}
this.$refs.hCaptcha.reset();
this.credentials.hCaptchaClientResponse = '';
this.didCaptchaReset = true;
},
},
};
</script>
<style scoped lang="scss">
.h-captcha--box {
margin-bottom: var(--space-small);
.captcha-error {
color: var(--r-400);
font-size: var(--font-size-small);
}
&::v-deep .error {
iframe {
border: 1px solid var(--r-500);
border-radius: var(--border-radius-normal);
}
}
}
.accept--terms {
margin: var(--space-normal) 0 var(--space-smaller) 0;
}
</style>

View file

@ -0,0 +1,119 @@
<template>
<div v-if="testimonials.length" class="testimonial--section">
<img src="/assets/images/auth/top-left.svg" class="top-left--img" />
<img src="/assets/images/auth/bottom-right.svg" class="bottom-right--img" />
<img src="/assets/images/auth/auth--bg.svg" class="center--img" />
<div class="testimonial--content">
<div class="testimonial--content-card">
<testimonial-card
v-for="(testimonial, index) in testimonials"
:key="testimonial.id"
:review-content="testimonial.authorReview"
:author-image="testimonial.authorImage"
:author-name="testimonial.authorName"
:author-designation="testimonial.authorCompany"
:class="`testimonial-${index ? 'right' : 'left'}--card`"
/>
</div>
</div>
</div>
</template>
<script>
import TestimonialCard from './TestimonialCard.vue';
import { getTestimonialContent } from 'dashboard/api/testimonials';
export default {
components: {
TestimonialCard,
},
data() {
return {
testimonials: [],
};
},
beforeMount() {
this.fetchTestimonials();
},
methods: {
async fetchTestimonials() {
try {
const { data } = await getTestimonialContent();
this.testimonials = data;
} catch (error) {
// Ignoring the error as the UI wouldn't break
} finally {
this.$emit('resize-containers', !!this.testimonials.length);
}
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/woot';
.top-left--img {
left: 0;
height: 16rem;
position: absolute;
top: 0;
width: 16rem;
}
.bottom-right--img {
bottom: 0;
height: 16rem;
position: absolute;
right: 0;
width: 16rem;
}
.center--img {
height: 96%;
left: 8%;
position: absolute;
top: 8%;
width: 86%;
}
.center-container {
padding: var(--space-medium) 0;
}
.testimonial--section {
background: var(--w-400);
display: flex;
flex: 1 1;
position: relative;
}
.testimonial--content {
align-content: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
z-index: 1000;
}
.testimonial--content-card {
align-items: flex-start;
display: flex;
justify-content: center;
padding: var(--space-large);
}
.testimonial-left--card {
--signup-testimonial-top: 20%;
margin-top: var(--signup-testimonial-top);
margin-right: var(--space-minus-normal);
z-index: 10000;
}
@media screen and (max-width: 1200px) {
.testimonial--section {
display: none;
}
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="testimonial-card">
<div class="left-card--wrap absolute">
<div class="left-card--content">
<p class="card-content">
{{ reviewContent }}
</p>
<div class="content-author--details row">
<div class="author-image--wrap">
<img :src="authorImage" class="author-image" />
</div>
<div class="author-name-company--details">
<div class="author-name">{{ authorName }}</div>
<div class="author-company">{{ authorDesignation }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
reviewContent: {
type: String,
default: '',
},
authorImage: {
type: String,
default: '',
},
authorName: {
type: String,
default: '',
},
authorDesignation: {
type: String,
default: '',
},
},
setup() {},
};
</script>
<style scoped lang="scss">
.testimonial-card {
align-items: center;
background: var(--white);
border-radius: var(--border-radius-normal);
box-shadow: var(--shadow-large);
display: flex;
justify-content: center;
padding: var(--space-medium) var(--space-large);
width: 32rem;
}
.content-author--details {
align-items: center;
display: flex;
margin-top: var(--space-small);
.author-image--wrap {
background: white;
border-radius: var(--border-radius-rounded);
padding: var(--space-smaller);
.author-image {
border-radius: var(--border-radius-rounded);
height: calc(var(--space-two) + var(--space-two));
width: calc(var(--space-two) + var(--space-two));
}
}
.author-name-company--details {
margin-left: var(--space-small);
.author-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
}
.author-company {
font-size: var(--font-size-mini);
}
}
}
.card-content {
color: var(--s-600);
// font-size: var(--font-size-default);
line-height: 1.7;
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div class="testimonial--footer">
<h2 class="heading">
{{ title }}
</h2>
<span class="sub-block-title sub-heading">
{{ subTitle }}
</span>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
},
};
</script>
<style scoped lang="scss">
.testimonial--footer {
align-items: center;
bottom: var(--space-jumbo);
display: flex;
flex-direction: column;
text-align: center;
margin: 0 auto;
padding: 0 var(--space-jumbo);
position: absolute;
width: 100%;
.heading {
color: var(--white);
font-size: var(--font-size-bigger);
}
.sub-heading {
color: var(--white);
font-weight: var(--font-weight-medium);
}
}
</style>

View file

@ -35,6 +35,9 @@
import alertMixin from 'shared/mixins/alertMixin';
import { mixin as clickaway } from 'vue-clickaway';
import MacroPreview from './MacroPreview';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../../helper/AnalyticsHelper';
export default {
components: {
MacroPreview,
@ -64,6 +67,7 @@ export default {
macroId: macro.id,
conversationIds: [this.conversationId],
});
AnalyticsHelper.track(ANALYTICS_EVENTS.EXECUTED_A_MACRO);
this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
} catch (error) {
this.showAlert(this.$t('MACROS.ERROR'));

View file

@ -171,11 +171,12 @@
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import { required } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import campaignMixin from 'shared/mixins/campaignMixin';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
@ -221,8 +222,23 @@ export default {
},
endPoint: {
required,
minLength: minLength(7),
url,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return (
value.startsWith('https://') || value.startsWith('http://')
);
}
return false;
},
},
timeOnPage: {
required,

View file

@ -30,7 +30,7 @@
<label :class="{ error: $v.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
<select v-model="selectedInbox" @change="onChangeInbox($event)">
<option v-for="item in inboxes" :key="item.name" :value="item.id">
<option v-for="item in inboxes" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
@ -111,10 +111,12 @@
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import { required } from 'vuelidate/lib/validators';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import alertMixin from 'shared/mixins/alertMixin';
import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
@ -152,8 +154,21 @@ export default {
},
endPoint: {
required,
minLength: minLength(7),
url,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return value.startsWith('https://') || value.startsWith('http://');
}
return false;
},
},
timeOnPage: {
required,

View file

@ -62,7 +62,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
export default {
mixins: [alertMixin],
@ -78,7 +78,7 @@ export default {
},
validations: {
inboxName: { required },
phoneNumber: { required, shouldStartWithPlusSign },
phoneNumber: { required, isPhoneE164OrEmpty },
apiKey: { required },
},
methods: {

View file

@ -99,8 +99,7 @@ import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
export default {
mixins: [alertMixin],
@ -118,10 +117,10 @@ export default {
},
validations: {
inboxName: { required },
phoneNumber: { required, shouldStartWithPlusSign },
phoneNumber: { required, isPhoneE164OrEmpty },
apiKey: { required },
phoneNumberId: { required },
businessAccountId: { required },
phoneNumberId: { required, isNumber },
businessAccountId: { required, isNumber },
},
methods: {
async createChannel() {

View file

@ -110,8 +110,7 @@ import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
import { isPhoneE164OrEmpty } from 'shared/helpers/Validators';
export default {
mixins: [alertMixin],
@ -142,7 +141,7 @@ export default {
return {
channelName: { required },
messagingServiceSID: {},
phoneNumber: { shouldStartWithPlusSign },
phoneNumber: { required, isPhoneE164OrEmpty },
authToken: { required },
accountSID: { required },
medium: { required },

View file

@ -7,6 +7,7 @@ import dashboard from './dashboard/dashboard.routes';
import login from './login/login.routes';
import store from '../store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes];
@ -117,6 +118,11 @@ export const validateRouteAccess = (to, from, next, { getters }) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
if (validateSSOLoginParams(to)) {
clearBrowserSessionCookies();
next();

View file

@ -10,6 +10,9 @@ import {
isOnUnattendedView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@ -171,6 +174,11 @@ const actions = {
status: MESSAGE_STATUS.PROGRESS,
});
const response = await MessageApi.create(pendingMessage);
AnalyticsHelper.track(
pendingMessage.private
? ANALYTICS_EVENTS.SENT_PRIVATE_NOTE
: ANALYTICS_EVENTS.SENT_MESSAGE
);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,

View file

@ -6,6 +6,9 @@ import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
const buildInboxData = inboxParams => {
const formData = new FormData();
@ -117,6 +120,12 @@ export const getters = {
},
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
@ -134,6 +143,8 @@ export const actions = {
const response = await WebChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
const { channel = {} } = params;
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -146,6 +157,7 @@ export const actions = {
const response = await WebChannel.create(buildInboxData(params));
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('website');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -158,6 +170,7 @@ export const actions = {
const response = await TwilioChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('twilio');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -170,6 +183,7 @@ export const actions = {
const response = await FBChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('facebook');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });

View file

@ -1,8 +1,3 @@
/* eslint no-console: 0 */
/* eslint-env browser */
/* eslint-disable no-new */
/* Vue Core */
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router';
@ -32,7 +27,6 @@ import constants from '../dashboard/constants';
import * as Sentry from '@sentry/vue';
import 'vue-easytable/libs/theme-default/index.css';
import { Integrations } from '@sentry/tracing';
import posthog from 'posthog-js';
import {
initializeAnalyticsEvents,
initializeChatwootEvents,
@ -40,6 +34,7 @@ import {
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import AnalyticsHelper from '../dashboard/helper/AnalyticsHelper';
Vue.config.env = process.env;
@ -51,12 +46,6 @@ if (window.errorLoggingConfig) {
});
}
if (window.analyticsConfig) {
posthog.init(window.analyticsConfig.token, {
api_host: window.analyticsConfig.host,
});
}
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.use(VueRouter);
Vue.use(VueI18n);
@ -90,6 +79,7 @@ window.WootConstants = constants;
window.axios = createAxios(axios);
window.bus = new Vue();
initializeChatwootEvents();
AnalyticsHelper.init();
initializeAnalyticsEvents();
initalizeRouter();

View file

@ -10,7 +10,7 @@ import {
widgetHolder,
createBubbleHolder,
createBubbleIcon,
bubbleImg,
bubbleSVG,
chatBubble,
closeBubble,
bubbleHolder,
@ -21,6 +21,7 @@ import {
addUnreadClass,
removeUnreadClass,
} from './bubbleHelpers';
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
@ -277,9 +278,14 @@ export const IFrameHelper = {
closeBtnClassName += ' woot-widget-bubble--flat';
}
if (isWidgetColorLighter(widgetColor)) {
className += ' woot-widget-bubble-color--lighter';
closeBtnClassName += ' woot-widget-bubble-color--lighter';
}
const chatIcon = createBubbleIcon({
className,
src: bubbleImg,
path: bubbleSVG,
target: chatBubble,
});

View file

@ -2,8 +2,8 @@ import { addClasses, removeClasses, toggleClass } from './DOMHelpers';
import { IFrameHelper } from './IFrameHelper';
import { isExpandedView } from './settingsHelper';
export const bubbleImg =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg==';
export const bubbleSVG =
'M240.808 240.808H122.123C56.6994 240.808 3.45695 187.562 3.45695 122.122C3.45695 56.7031 56.6994 3.45697 122.124 3.45697C187.566 3.45697 240.808 56.7031 240.808 122.122V240.808Z';
export const body = document.getElementsByTagName('body')[0];
export const widgetHolder = document.createElement('div');
@ -20,11 +20,27 @@ export const setBubbleText = bubbleText => {
}
};
export const createBubbleIcon = ({ className, src, target }) => {
export const createBubbleIcon = ({ className, path, target }) => {
let bubbleClassName = `${className} woot-elements--${window.$chatwoot.position}`;
const bubbleIcon = document.createElement('img');
bubbleIcon.src = src;
bubbleIcon.alt = 'bubble-icon';
const bubbleIcon = document.createElementNS(
'http://www.w3.org/2000/svg',
'svg'
);
bubbleIcon.setAttributeNS(null, 'id', 'woot-widget-bubble-icon');
bubbleIcon.setAttributeNS(null, 'width', '24');
bubbleIcon.setAttributeNS(null, 'height', '24');
bubbleIcon.setAttributeNS(null, 'viewBox', '0 0 240 240');
bubbleIcon.setAttributeNS(null, 'fill', 'none');
bubbleIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const bubblePath = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
bubblePath.setAttributeNS(null, 'd', path);
bubblePath.setAttributeNS(null, 'fill', '#FFFFFF');
bubbleIcon.appendChild(bubblePath);
target.appendChild(bubbleIcon);
if (isExpandedView(window.$chatwoot.type)) {

View file

@ -1,6 +1,7 @@
export const SDK_CSS = `
:root {
--b-100: #F2F3F7;
--s-700: #37546D;
}
.woot-widget-holder {
@ -64,7 +65,7 @@ export const SDK_CSS = `
width: 56px;
}
.woot-widget-bubble.woot-widget-bubble--flat img {
.woot-widget-bubble.woot-widget-bubble--flat svg {
margin: 16px;
}
@ -107,7 +108,11 @@ export const SDK_CSS = `
width: auto !important;
}
.woot-widget-bubble.woot-widget--expanded img {
.woot-widget-bubble.woot-widget--expanded.woot-widget-bubble-color--lighter div{
color: var(--s-700);
}
.woot-widget-bubble.woot-widget--expanded svg {
height: 20px;
margin: 14px 8px 14px 16px;
width: 20px;
@ -126,13 +131,17 @@ export const SDK_CSS = `
box-shadow: 0 8px 32px rgba(0, 0, 0, .4) !important;
}
.woot-widget-bubble img {
.woot-widget-bubble svg {
all: revert;
height: 24px;
margin: 20px;
width: 24px;
}
.woot-widget-bubble.woot-widget-bubble-color--lighter path{
fill: var(--s-700);
}
@media only screen and (min-width: 667px) {
.woot-widget-holder.woot-elements--left {
left: 20px;
@ -157,6 +166,10 @@ export const SDK_CSS = `
width: 2px;
}
.woot-widget-bubble-color--lighter.woot--close::before, .woot-widget-bubble-color--lighter.woot--close::after {
background-color: var(--s-700);
}
.woot--close::before {
transform: rotate(45deg);
}

View file

@ -94,6 +94,7 @@
"list-outline": "M2.75 18h12.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.494L2.75 18h12.5-12.5Zm0-6.5h18.5a.75.75 0 0 1 .102 1.493L21.25 13H2.75a.75.75 0 0 1-.102-1.493l.102-.007h18.5-18.5Zm0-6.497h15.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493l.102-.007h15.5-15.5Z",
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",
@ -117,6 +118,7 @@
"people-outline": "M4 13.999 13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14ZM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78ZM4 15.499l-.1.01a.51.51 0 0 0-.254.136.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242.826.445 2.003.714 3.266.753l.317.005.317-.005c1.263-.039 2.439-.308 3.266-.753.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001ZM8.5 3a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm9 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3-1.346-3-3-3Zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2 2-.897 2-2-.897-2-2-2Z",
"people-team-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11Zm0 2l-.09.007a.5.5 0 0 0-.402.402L17 14.5V17h-2.502l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17v2.503l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402l.008-.09V18l2.504.001l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.403-.402l-.09-.008H18v-2.5l-.008-.09a.5.5 0 0 0-.402-.403L17.5 14Zm-3.246-4c.835 0 1.563.454 1.951 1.13a6.44 6.44 0 0 0-1.518.509a.736.736 0 0 0-.433-.139H9.752a.75.75 0 0 0-.75.75v4.249c0 1.41.974 2.594 2.286 2.915a6.42 6.42 0 0 0 .735 1.587l-.02-.001a4.501 4.501 0 0 1-4.501-4.501V12.25A2.25 2.25 0 0 1 9.752 10h4.502Zm-6.848 0a3.243 3.243 0 0 0-.817 1.5H4.25a.75.75 0 0 0-.75.75v2.749a2.501 2.501 0 0 0 3.082 2.433c.085.504.24.985.453 1.432A4.001 4.001 0 0 1 2 14.999V12.25a2.25 2.25 0 0 1 2.096-2.245L4.25 10h3.156Zm12.344 0A2.25 2.25 0 0 1 22 12.25v.56A6.478 6.478 0 0 0 17.5 11l-.245.005A3.21 3.21 0 0 0 16.6 10h3.15ZM18.5 4a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5ZM12 3a3 3 0 1 1 0 6a3 3 0 0 1 0-6ZM5.5 4a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5Zm13 1.5a1 1 0 1 0 0 2a1 1 0 0 0 0-2Zm-6.5-1a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3Zm-6.5 1a1 1 0 1 0 0 2a1 1 0 0 0 0-2Z",
"people-team-outline": "M14.754 10c.966 0 1.75.784 1.75 1.75v4.749a4.501 4.501 0 0 1-9.002 0V11.75c0-.966.783-1.75 1.75-1.75h5.502Zm0 1.5H9.252a.25.25 0 0 0-.25.25v4.749a3.001 3.001 0 0 0 6.002 0V11.75a.25.25 0 0 0-.25-.25ZM3.75 10h3.381a2.738 2.738 0 0 0-.618 1.5H3.75a.25.25 0 0 0-.25.25v3.249a2.501 2.501 0 0 0 3.082 2.433c.085.504.24.985.453 1.432A4.001 4.001 0 0 1 2 14.999V11.75c0-.966.784-1.75 1.75-1.75Zm13.125 0h3.375c.966 0 1.75.784 1.75 1.75V15a4 4 0 0 1-5.03 3.866c.214-.448.369-.929.455-1.433A2.5 2.5 0 0 0 20.5 15v-3.25a.25.25 0 0 0-.25-.25h-2.757a2.738 2.738 0 0 0-.618-1.5ZM12 3a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm6.5 1a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Zm-13 0a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Zm6.5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm6.5 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-13 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
"person-account-outline": "M11 15c0-.35.06-.687.17-1H4.253a2.249 2.249 0 0 0-2.249 2.249v.578c0 .892.319 1.756.899 2.435 1.566 1.834 3.952 2.74 7.098 2.74.397 0 .783-.015 1.156-.044A2.998 2.998 0 0 1 11 21v-.535c-.321.024-.655.036-1 .036-2.738 0-4.704-.746-5.958-2.213a2.25 2.25 0 0 1-.539-1.462v-.577c0-.414.336-.75.75-.75H11V15ZM10 2.005a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7ZM12 15a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-6Zm2.5 1a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Zm0 3a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6Z",
"person-add-outline": "M17.5 12a5.5 5.5 0 1 1 0 11a5.5 5.5 0 0 1 0-11zm-5.477 2a6.47 6.47 0 0 0-.709 1.5H4.253a.749.749 0 0 0-.75.75v.577c0 .535.192 1.053.54 1.46c1.253 1.469 3.22 2.214 5.957 2.214c.597 0 1.157-.035 1.68-.106c.246.495.553.954.912 1.367c-.795.16-1.66.24-2.592.24c-3.146 0-5.532-.906-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.578A2.249 2.249 0 0 1 4.253 14h7.77zm5.477 0l-.09.008a.5.5 0 0 0-.402.402L17 14.5V17h-2.496l-.09.008a.5.5 0 0 0-.402.402l-.008.09l.008.09a.5.5 0 0 0 .402.402l.09.008H17L17 20.5l.008.09a.5.5 0 0 0 .402.402l.09.008l.09-.008a.5.5 0 0 0 .402-.402L18 20.5V18h2.504l.09-.008a.5.5 0 0 0 .402-.402l.008-.09l-.008-.09a.5.5 0 0 0-.402-.402l-.09-.008H18L18 14.5l-.008-.09a.5.5 0 0 0-.402-.402L17.5 14zM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z",
"person-assign-outline": "M11.313 15.5a6.471 6.471 0 0 1 .709-1.5h-7.77a2.249 2.249 0 0 0-2.249 2.25v.577c0 .892.319 1.756.899 2.435c1.566 1.834 3.952 2.74 7.098 2.74c.931 0 1.796-.08 2.592-.24a6.51 6.51 0 0 1-.913-1.366c-.524.07-1.083.105-1.68.105c-2.737 0-4.703-.745-5.957-2.213a2.25 2.25 0 0 1-.539-1.461v-.578a.75.75 0 0 1 .75-.749h7.06ZM10 2.005a5 5 0 1 1 0 10a5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7ZM23 17.5a5.5 5.5 0 1 1-11 0a5.5 5.5 0 0 1 11 0Zm-4.647-2.853a.5.5 0 0 0-.707.707L19.293 17H15a.5.5 0 1 0 0 1h4.293l-1.647 1.647a.5.5 0 0 0 .707.707l2.5-2.5a.497.497 0 0 0 .147-.345V17.5a.498.498 0 0 0-.15-.357l-2.497-2.496Z",
"person-outline": "M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438-1.57 1.834-3.957 2.739-7.102 2.739-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501Zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461 1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75ZM12 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",

View file

@ -33,7 +33,7 @@ export default {
margin-left: -$space-one;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.7);
border-top-color: lighten($color-woot, 10%);
border-top-color: rgba(255, 255, 255, 0.2);
animation: spinner 0.9s linear infinite;
}
}

View file

@ -16,3 +16,4 @@ export const isValidPassword = value => {
containsSpecialCharacter
);
};
export const isNumber = value => /^\d+$/.test(value);

View file

@ -0,0 +1,8 @@
export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
const c_g = parseInt(colorToCheck.substr(2, 2), 16);
const c_b = parseInt(colorToCheck.substr(4, 2), 16);
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225;
};

View file

@ -1,5 +1,6 @@
import { shouldBeUrl } from '../Validators';
import { isValidPassword } from '../Validators';
import { isNumber } from '../Validators';
describe('#shouldBeUrl', () => {
it('should return correct url', () => {
@ -22,3 +23,15 @@ describe('#isValidPassword', () => {
expect(isValidPassword('testPass!')).toEqual(false);
});
});
describe('#isNumber', () => {
it('should return correct number', () => {
expect(isNumber('123')).toEqual(true);
});
it('should return wrong number', () => {
expect(isNumber('123-')).toEqual(false);
expect(isNumber('123./')).toEqual(false);
expect(isNumber('string')).toEqual(false);
});
});

View file

@ -0,0 +1,10 @@
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => {
expect(isWidgetColorLighter('#ffffff')).toEqual(true);
});
it('returns false if color is darker', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false);
});
});

View file

@ -1,10 +1,10 @@
<template>
<div class="file flex flex-row items-center p-3 cursor-pointer">
<div class="icon-wrap">
<div class="icon-wrap" :style="{ color: textColor }">
<fluent-icon icon="document" size="28" />
</div>
<div class="meta">
<div class="title">
<div class="title" :style="{ color: textColor }">
{{ title }}
</div>
<div class="link-wrap mb-1">
@ -12,6 +12,7 @@
class="download"
rel="noreferrer noopener nofollow"
target="_blank"
:style="{ color: textColor }"
:href="url"
>
{{ $t('COMPONENTS.FILE_BUBBLE.DOWNLOAD') }}
@ -23,6 +24,7 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
components: {
@ -51,6 +53,9 @@ export default {
fileName() {
return this.url.substring(this.url.lastIndexOf('/') + 1);
},
textColor() {
return getContrastingTextColor(this.widgetColor);
},
},
methods: {
openLink() {

View file

@ -14,6 +14,12 @@
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
{{ error }}
</div>
<div
v-if="!error && helpText"
class="text-red-400 mt-2 text-xs font-medium"
>
{{ helpText }}
</div>
</label>
</template>
<script>
@ -41,6 +47,10 @@ export default {
type: String,
default: '',
},
helpText: {
type: String,
default: '',
},
},
computed: {
labelClass() {

View file

@ -27,7 +27,15 @@
class="button clear-button"
@click="openConversationView"
>
<span class="flex items-center">
<span
class="flex items-center"
:class="{
'is-background-light': isBackgroundLighter,
}"
:style="{
color: widgetColor,
}"
>
<fluent-icon class="mr-2" size="16" icon="arrow-right" />
{{ $t('UNREAD_VIEW.VIEW_MESSAGES_BUTTON') }}
</span>
@ -43,6 +51,7 @@ import configMixin from '../mixins/configMixin';
import { ON_UNREAD_MESSAGE_CLICK } from '../constants/widgetBusEvents';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import UnreadMessage from 'widget/components/UnreadMessage.vue';
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
export default {
name: 'Unread',
@ -58,11 +67,17 @@ export default {
},
},
computed: {
...mapGetters({ unreadMessageCount: 'conversation/getUnreadMessageCount' }),
...mapGetters({
unreadMessageCount: 'conversation/getUnreadMessageCount',
widgetColor: 'appConfig/getWidgetColor',
}),
sender() {
const [firstMessage] = this.messages;
return firstMessage.sender || {};
},
isBackgroundLighter() {
return isWidgetColorLighter(this.widgetColor);
},
},
methods: {
openConversationView() {
@ -134,5 +149,8 @@ export default {
color: $color-body;
}
}
.is-background-light {
color: $color-body !important;
}
}
</style>

View file

@ -28,6 +28,7 @@
v-else
:url="attachment.data_url"
:is-in-progress="isInProgress"
:widget-color="widgetColor"
/>
</div>
</div>

View file

@ -1,5 +1,19 @@
export const stripTrailingSlash = ({ URL }) => {
return URL.replace(/\/$/, '');
import { URLPattern } from 'urlpattern-polyfill';
export const isPatternMatchingWithURL = (urlPattern, url) => {
let updatedUrlPattern = urlPattern;
const locationObj = new URL(url);
if (updatedUrlPattern.endsWith('/')) {
updatedUrlPattern = updatedUrlPattern.slice(0, -1) + '*\\?*\\#*';
}
if (locationObj.pathname.endsWith('/')) {
locationObj.pathname = locationObj.pathname.slice(0, -1);
}
const pattern = new URLPattern(updatedUrlPattern);
return pattern.test(locationObj.toString());
};
// Format all campaigns
@ -22,10 +36,7 @@ export const filterCampaigns = ({
isInBusinessHours,
}) => {
return campaigns.filter(campaign => {
const hasMatchingURL =
stripTrailingSlash({ URL: campaign.url }) ===
stripTrailingSlash({ URL: currentURL });
if (!hasMatchingURL) {
if (!isPatternMatchingWithURL(campaign.url, currentURL)) {
return false;
}
if (campaign.triggerOnlyDuringBusinessHours) {

View file

@ -1,7 +1,7 @@
import {
stripTrailingSlash,
formatCampaigns,
filterCampaigns,
isPatternMatchingWithURL,
} from '../campaignHelper';
import campaigns from './campaignFixtures';
@ -9,11 +9,35 @@ global.chatwootWebChannel = {
workingHoursEnabled: false,
};
describe('#Campaigns Helper', () => {
describe('stripTrailingSlash', () => {
it('should return striped trailing slash if url with trailing slash is passed', () => {
describe('#isPatternMatchingWithURL', () => {
it('returns correct value if a valid URL is passed', () => {
expect(
stripTrailingSlash({ URL: 'https://www.chatwoot.com/pricing/' })
).toBe('https://www.chatwoot.com/pricing');
isPatternMatchingWithURL(
'https://chatwoot.com/pricing*',
'https://chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://*.chatwoot.com/pricing/',
'https://app.chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing?test=true',
'https://app.chatwoot.com/pricing/?test=true'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing*\\?*',
'https://chatwoot.com/pricing/?test=true'
)
).toBe(true);
});
});

View file

@ -70,12 +70,15 @@ class Attachment < ApplicationRecord
private
def file_metadata
{
metadata = {
extension: extension,
data_url: file_url,
thumb_url: thumb_url,
file_size: file.byte_size
}
metadata[:data_url] = metadata[:thumb_url] = external_url if message.instagram_story_mention?
metadata
end
def location_metadata

View file

@ -86,7 +86,8 @@ class Campaign < ApplicationRecord
def validate_url
return unless trigger_rules['url']
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !url_valid?(trigger_rules['url'])
use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
end
def prevent_completed_campaign_from_update

View file

@ -63,4 +63,21 @@ class Channel::FacebookPage < ApplicationRecord
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
def fetch_instagram_story_link(message)
k = Koala::Facebook::API.new(page_access_token)
result = k.get_object(message.source_id, fields: %w[story]) || {}
story_link = result['story']['mention']['link']
# If the story is expired then it raises the ClientError and if the story is deleted with valid story-id it responses with nil
delete_instagram_story(message) if story_link.blank?
story_link
rescue Koala::Facebook::ClientError => e
delete_instagram_story(message)
end
def delete_instagram_story(message)
message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'), content_attributes: {})
message.attachments.destroy_all
end
end

View file

@ -16,4 +16,8 @@ module MessageFilterHelpers
def email_reply_summarizable?
incoming? || outgoing? || input_csat?
end
def instagram_story_mention?
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
end
end

View file

@ -78,6 +78,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::FacebookPage'
end
def instagram?
facebook? && channel.instagram_id.present?
end
def web_widget?
channel_type == 'Channel::WebWidget'
end

View file

@ -107,10 +107,20 @@ class Message < ApplicationRecord
conversation: { assignee_id: conversation.assignee_id }
)
data.merge!(echo_id: echo_id) if echo_id.present?
validate_instagram_story if instagram_story_mention?
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
merge_sender_attributes(data)
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
# Better logic is to listen to webhook and remove stories proactively rather than trying
# a fetch every time a message is returned
def validate_instagram_story
inbox.channel.fetch_instagram_story_link(self)
# we want to reload the message in case the story has expired and data got removed
reload
end
def merge_sender_attributes(data)
data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot)
data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot)

View file

@ -31,7 +31,7 @@
</label>
<label>
<span>Company Name</span>
<%= text_field :user, :company, placeholder: "Enter an account name. eg: Wayne Enterprises", required: true %>
<%= text_field :user, :company, placeholder: "Enter your company name. eg: Wayne Enterprises", required: true %>
</label>
<label>
<span>Work Email</span>

View file

@ -51,11 +51,10 @@
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
</script>
<% if @global_config['ANALYTICS_TOKEN'].present? && @global_config['ANALYTICS_HOST'].present? %>
<% if @global_config['ANALYTICS_TOKEN'].present? %>
<script>
window.analyticsConfig = {
token: '<%= @global_config['ANALYTICS_TOKEN'] %>',
host: '<%= @global_config['ANALYTICS_HOST'] %>',
}
</script>
<% end %>

View file

@ -42,8 +42,6 @@
value:
- name: ANALYTICS_TOKEN
value:
- name: ANALYTICS_HOST
value:
- name: DIRECT_UPLOADS_ENABLED
value: false
locked: false

View file

@ -22,6 +22,7 @@
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517",
"@chatwoot/utils": "^0.0.10",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
"@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.0.3-1",
"@rails/webpacker": "5.3.0",
@ -33,6 +34,7 @@
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"chart.js": "~2.9.4",
"company-email-validator": "^1.0.8",
"core-js": "3.11.0",
"date-fns": "2.21.1",
"date-fns-tz": "^1.3.3",
@ -45,7 +47,6 @@
"md5": "^2.3.0",
"ninja-keys": "^1.1.9",
"opus-recorder": "^8.0.5",
"posthog-js": "^1.13.7",
"prosemirror-markdown": "1.5.1",
"prosemirror-state": "1.3.4",
"prosemirror-view": "1.18.4",
@ -54,6 +55,7 @@
"tailwindcss": "^1.9.6",
"turbolinks": "^5.2.0",
"url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2",
"v-tooltip": "~2.1.3",
"videojs-record": "^4.5.0",
"vue": "2.6.12",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,3 @@
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M800 0H600V200H400V400H200V600H0V800H200H400H600H800V600V400V200V0Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View file

@ -0,0 +1,3 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M200 0H0V200V400V600H200V400H400V200H600V0H400H200Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

View file

@ -68,7 +68,7 @@ describe ::Messages::Instagram::MessageBuilder do
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(1)
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do

View file

@ -6,5 +6,9 @@ FactoryBot.define do
user_access_token { SecureRandom.uuid }
page_id { SecureRandom.uuid }
account
before :create do |_channel|
WebMock::API.stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
end
end
end

View file

@ -8,6 +8,18 @@ FactoryBot.define do
content_type { 'text' }
account { create(:account) }
trait :instagram_story_mention do
content_attributes { { image_type: 'story_mention' } }
after(:build) do |message|
unless message.inbox.instagram?
message.inbox = create(:inbox, account: message.account,
channel: create(:channel_instagram_fb_page, account: message.account, instagram_id: 'instagram-123'))
end
attachment = message.attachments.new(account_id: message.account_id, file_type: :image, external_url: 'https://www.example.com/test.jpeg')
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
end
end
after(:build) do |message|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)

View file

@ -113,6 +113,9 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
attachment = instagram_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end
it 'creates does not create contact or messages' do

View file

@ -9,4 +9,23 @@ RSpec.describe Attachment, type: :model do
expect(attachment.download_url).not_to be_nil
end
end
describe 'push_event_data for instagram story mentions' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'returns external url as data and thumb urls' do
external_url = instagram_message.attachments.first.external_url
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
expect(instagram_message.attachments.first.push_event_data[:thumb_url]).to eq external_url
end
end
end

View file

@ -180,4 +180,34 @@ RSpec.describe Message, type: :model do
expect(message.email_notifiable_message?).to be true
end
end
context 'when facebook channel with unavailable story link' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'deletes the attachment for deleted stories' do
expect(instagram_message.attachments.count).to eq 1
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 404)
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
it 'deletes the attachment for expired stories' do
expect(instagram_message.attachments.count).to eq 1
# for expired stories, the link will be empty
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: '', id: '17920786367196703' } }
}.to_json, headers: {})
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
end
end

1022
yarn.lock

File diff suppressed because it is too large Load diff