Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7520ca7a99 | ||
|
|
83967735d3 | ||
|
|
19cfd4be4c | ||
|
|
161024db9d | ||
|
|
a7e73de8d4 | ||
|
|
ca02b442dd | ||
|
|
4efeb4eda2 | ||
|
|
c88447c11f | ||
|
|
27f7e0921e | ||
|
|
f3b8777ebf | ||
|
|
73f6b888cd | ||
|
|
b8fc921f9a | ||
|
|
990d045cb3 | ||
|
|
7fcb0d6880 | ||
|
|
9631b0b929 | ||
|
|
0a2fd7b1f4 | ||
|
|
11a7414dc0 | ||
|
|
9a4c1e1fb9 | ||
|
|
098a6263a9 | ||
|
|
7590faaa63 | ||
|
|
031199f15a | ||
|
|
8a5b007bc4 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
@@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.2.0'
|
||||
gem 'slack-ruby-client', '~> 2.5.1'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'grpc'
|
||||
|
||||
+10
-7
@@ -272,7 +272,8 @@ GEM
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
geocoder (1.8.1)
|
||||
gli (2.21.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
gmail_xoauth (0.4.3)
|
||||
@@ -497,14 +498,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.2)
|
||||
nokogiri (1.18.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -537,6 +538,7 @@ GEM
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.1)
|
||||
ast (~> 2.4.1)
|
||||
@@ -745,12 +747,13 @@ GEM
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
slack-ruby-client (2.2.0)
|
||||
slack-ruby-client (2.5.1)
|
||||
faraday (>= 2.0)
|
||||
faraday-mashify
|
||||
faraday-multipart
|
||||
gli
|
||||
hashie
|
||||
logger
|
||||
snaky_hash (2.0.1)
|
||||
hashie
|
||||
version_gem (~> 1.1, >= 1.1.1)
|
||||
@@ -951,7 +954,7 @@ DEPENDENCIES
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
slack-ruby-client (~> 2.5.1)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
def create
|
||||
authorize @inbox, :create?
|
||||
ActiveRecord::Base.transaction do
|
||||
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
|
||||
@inbox.add_members(agents_to_be_added_ids)
|
||||
end
|
||||
fetch_updated_agents
|
||||
end
|
||||
@@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
def destroy
|
||||
authorize @inbox, :destroy?
|
||||
ActiveRecord::Base.transaction do
|
||||
params[:user_ids].map { |user_id| @inbox.remove_member(user_id) }
|
||||
@inbox.remove_members(params[:user_ids])
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
@@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
# the missing ones are the agents which are to be deleted from the inbox
|
||||
# the new ones are the agents which are to be added to the inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
|
||||
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
|
||||
@inbox.add_members(agents_to_be_added_ids)
|
||||
@inbox.remove_members(agents_to_be_removed_ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
|
||||
@team_members = @team.add_members(members_to_be_added_ids)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
members_to_be_added_ids.each { |user_id| @team.add_member(user_id) }
|
||||
members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) }
|
||||
@team.add_members(members_to_be_added_ids)
|
||||
@team.remove_members(members_to_be_removed_ids)
|
||||
end
|
||||
@team_members = @team.members
|
||||
render action: 'create'
|
||||
@@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
|
||||
|
||||
def destroy
|
||||
ActiveRecord::Base.transaction do
|
||||
params[:user_ids].map { |user_id| @team.remove_member(user_id) }
|
||||
@team.remove_members(params[:user_ids])
|
||||
end
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV',
|
||||
'CSML_EDITOR_HOST'
|
||||
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
|
||||
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
|
||||
end
|
||||
|
||||
def since_timestamp(date)
|
||||
(date - 6.days).to_i.to_s
|
||||
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
|
||||
(date - number_of_days).to_i.to_s
|
||||
end
|
||||
|
||||
def until_timestamp(date)
|
||||
|
||||
@@ -7,13 +7,46 @@ module SuperAdmin::AccountFeaturesHelper
|
||||
account_features.filter { |feature| feature['premium'] }.pluck('name')
|
||||
end
|
||||
|
||||
# Accepts account.features as argument
|
||||
def self.filtered_features(features)
|
||||
deployment_env = GlobalConfig.get_value('DEPLOYMENT_ENV')
|
||||
return features if deployment_env == 'cloud'
|
||||
# Returns a hash mapping feature names to their display names
|
||||
def self.feature_display_names
|
||||
account_features.each_with_object({}) do |feature, hash|
|
||||
hash[feature['name']] = feature['display_name']
|
||||
end
|
||||
end
|
||||
|
||||
def self.filter_internal_features(features)
|
||||
return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||
|
||||
# Filter out internal features for non-cloud environments
|
||||
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
|
||||
features.except(*internal_features)
|
||||
end
|
||||
|
||||
def self.filter_deprecated_features(features)
|
||||
deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name')
|
||||
features.except(*deprecated_features)
|
||||
end
|
||||
|
||||
def self.sort_and_transform_features(features, display_names)
|
||||
features.sort_by { |key, _| display_names[key] || key }
|
||||
.to_h
|
||||
.transform_keys { |key| [key, display_names[key]] }
|
||||
end
|
||||
|
||||
def self.partition_features(features)
|
||||
filtered = filter_internal_features(features)
|
||||
filtered = filter_deprecated_features(filtered)
|
||||
display_names = feature_display_names
|
||||
|
||||
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
|
||||
|
||||
[
|
||||
sort_and_transform_features(regular, display_names),
|
||||
sort_and_transform_features(premium, display_names)
|
||||
]
|
||||
end
|
||||
|
||||
def self.filtered_features(features)
|
||||
regular, premium = partition_features(features)
|
||||
regular.merge(premium)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getConversationTrafficCSV() {
|
||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||
return axios.get(`${this.url}/conversation_traffic`, {
|
||||
params: { timezone_offset: getTimeOffset() },
|
||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,3 +6,209 @@
|
||||
body {
|
||||
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
// FIXME: Use a common color file for all packs
|
||||
// scss-lint:disable PropertySortOrder
|
||||
:root {
|
||||
--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-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-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-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-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-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-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-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-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-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-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-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const { featureFlag } = defineProps({
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
@@ -50,10 +50,10 @@ const { featureFlag } = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { shouldShowPaywall } = usePolicy();
|
||||
|
||||
const showPaywall = computed(() => {
|
||||
return !isCloudFeatureEnabled(featureFlag);
|
||||
return shouldShowPaywall(props.featureFlag);
|
||||
});
|
||||
|
||||
const handleButtonClick = () => {
|
||||
@@ -97,7 +97,7 @@ const handlePageChange = event => {
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
||||
<slot name="controls" />
|
||||
<slot v-if="!showPaywall" name="controls" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<script setup>
|
||||
import { useAttrs } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const attrs = useAttrs();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
v-if="globalConfig.logoThumbnail"
|
||||
v-bind="attrs"
|
||||
:src="globalConfig.logoThumbnail"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
v-once
|
||||
v-bind="attrs"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
|
||||
@@ -414,6 +414,11 @@ const avatarInfo = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
|
||||
const setupHighlightTimer = () => {
|
||||
if (Number(route.query.messageId) !== Number(props.id)) {
|
||||
return;
|
||||
@@ -472,6 +477,7 @@ provideMessageContext({
|
||||
>
|
||||
<div
|
||||
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
||||
v-tooltip.right-end="avatarTooltip"
|
||||
class="[grid-area:avatar] flex items-end"
|
||||
>
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
|
||||
@@ -31,7 +31,7 @@ const senderName = computed(() => {
|
||||
<template>
|
||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
|
||||
<div class="grid gap-4 min-w-64">
|
||||
<div class="grid gap-3 z-20">
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
class="size-8 rounded-lg grid place-content-center"
|
||||
:class="iconBgColor"
|
||||
|
||||
@@ -2,34 +2,30 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import DyteAPI from 'dashboard/api/integrations/dyte';
|
||||
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import BaseAttachmentBubble from './BaseAttachment.vue';
|
||||
|
||||
const { content, sender, contentAttributes } = useMessageContext();
|
||||
const { content, sender, id } = useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const meetingData = computed(() => {
|
||||
return useCamelCase(contentAttributes.value.data);
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const dyteAuthToken = ref('');
|
||||
|
||||
const meetingLink = computed(() => {
|
||||
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value);
|
||||
return buildDyteURL(dyteAuthToken.value);
|
||||
});
|
||||
|
||||
const joinTheCall = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { data: { authResponse: { authToken } = {} } = {} } =
|
||||
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId);
|
||||
dyteAuthToken.value = authToken;
|
||||
const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
|
||||
id.value
|
||||
);
|
||||
dyteAuthToken.value = token;
|
||||
} catch (err) {
|
||||
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
|
||||
} finally {
|
||||
@@ -38,7 +34,7 @@ const joinTheCall = async () => {
|
||||
};
|
||||
|
||||
const leaveTheRoom = () => {
|
||||
this.dyteAuthToken = '';
|
||||
dyteAuthToken.value = '';
|
||||
};
|
||||
const action = computed(() => ({
|
||||
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
|
||||
@@ -53,7 +49,7 @@ const action = computed(() => ({
|
||||
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
|
||||
:action="action"
|
||||
>
|
||||
<div v-if="!sender" class="text-n-slate-12 text-sm truncate">
|
||||
<div v-if="!sender" class="text-sm truncate text-n-slate-12">
|
||||
<!-- Added as a fallback, where the sender is not available (Deleted) -->
|
||||
<!-- Will show the content, if senderName in BaseAttachment.vue is empty -->
|
||||
{{ content }}
|
||||
@@ -64,7 +60,7 @@ const action = computed(() => ({
|
||||
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
|
||||
/>
|
||||
<button
|
||||
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-n-solid-3 mt-3"
|
||||
@click="leaveTheRoom"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, onMounted } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { EmailQuoteExtractor } from './removeReply.js';
|
||||
@@ -93,6 +94,11 @@ const hasQuotedMessage = computed(() => {
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
:html="fullHTML"
|
||||
:text="textToShow"
|
||||
/>
|
||||
@@ -100,6 +106,11 @@ const hasQuotedMessage = computed(() => {
|
||||
v-else
|
||||
class-name="prose prose-bubble !max-w-none"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
:text="textToShow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -25,16 +25,22 @@ const isPlaying = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
const playbackSpeed = ref(1);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
// There maybe a chance that the audioPlayer ref is not available
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
});
|
||||
|
||||
const formatTime = time => {
|
||||
@@ -72,6 +78,16 @@ const playOrPause = () => {
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
playbackSpeed.value = 1;
|
||||
audioPlayer.value.playbackRate = 1;
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = () => {
|
||||
const speeds = [1, 1.5, 2];
|
||||
const currentIndex = speeds.indexOf(playbackSpeed.value);
|
||||
const nextIndex = (currentIndex + 1) % speeds.length;
|
||||
playbackSpeed.value = speeds[nextIndex];
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
};
|
||||
|
||||
const downloadAudio = async () => {
|
||||
@@ -106,7 +122,7 @@ const downloadAudio = async () => {
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
<div class="flex items-center px-2">
|
||||
<div class="flex-1 items-center flex px-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -116,6 +132,14 @@ const downloadAudio = async () => {
|
||||
@input="seek"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
|
||||
@click="changePlaybackSpeed"
|
||||
>
|
||||
<span class="text-xs text-n-slate-11 font-medium">
|
||||
{{ playbackSpeedLabel }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="toggleMute"
|
||||
|
||||
@@ -240,24 +240,20 @@ const menuItems = computed(() => {
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
showOnlyOnCloud: true,
|
||||
children: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_assistants_index'),
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_documents_index'),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('captain_responses_index'),
|
||||
},
|
||||
],
|
||||
@@ -509,7 +505,6 @@ const menuItems = computed(() => {
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
icon: 'i-lucide-credit-card',
|
||||
showOnlyOnCloud: true,
|
||||
to: accountScopedRoute('billing_settings_index'),
|
||||
},
|
||||
],
|
||||
@@ -525,7 +520,7 @@ const menuItems = computed(() => {
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||
<Logo />
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
|
||||
@@ -24,7 +24,6 @@ const {
|
||||
resolvePath,
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isOnChatwootCloud,
|
||||
isAllowed,
|
||||
} = useSidebarContext();
|
||||
|
||||
@@ -43,7 +42,6 @@ const hasChildren = computed(
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
return props.children.filter(child => {
|
||||
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
// If a item has no link, it means it's just a subgroup header
|
||||
// So we don't need to check for permissions here, because there's nothing to
|
||||
// access here anyway
|
||||
@@ -166,7 +164,7 @@ onMounted(async () => {
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else
|
||||
v-else-if="isAllowed(child.to)"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:active="activeChild?.name === child.name"
|
||||
|
||||
@@ -9,18 +9,10 @@ const props = defineProps({
|
||||
to: { type: [String, Object], required: true },
|
||||
icon: { type: [String, Object], default: null },
|
||||
active: { type: Boolean, default: false },
|
||||
showOnlyOnCloud: { type: Boolean, default: false },
|
||||
component: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
|
||||
useSidebarContext();
|
||||
|
||||
const allowedToShow = computed(() => {
|
||||
if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
|
||||
|
||||
const shouldRenderComponent = computed(() => {
|
||||
return typeof props.component === 'function' || isVNode(props.component);
|
||||
@@ -30,7 +22,6 @@ const shouldRenderComponent = computed(() => {
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<Policy
|
||||
v-if="allowedToShow"
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
|
||||
@@ -14,12 +14,11 @@ const props = defineProps({
|
||||
activeChild: { type: Object, default: undefined },
|
||||
});
|
||||
|
||||
const { isAllowed, isOnChatwootCloud } = useSidebarContext();
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
|
||||
const accessibleItems = computed(() =>
|
||||
props.children.filter(child => {
|
||||
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
|
||||
return child.to && isAllowed(child.to);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -12,9 +11,8 @@ export function useSidebarContext() {
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
|
||||
const { checkFeatureAllowed, checkPermissions } = usePolicy();
|
||||
const { shouldShow } = usePolicy();
|
||||
|
||||
const resolvePath = to => {
|
||||
if (to) return router.resolve(to)?.path || '/';
|
||||
@@ -31,11 +29,17 @@ export function useSidebarContext() {
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveInstallationType = to => {
|
||||
if (to) return router.resolve(to)?.meta?.installationTypes || [];
|
||||
return [];
|
||||
};
|
||||
|
||||
const isAllowed = to => {
|
||||
const permissions = resolvePermissions(to);
|
||||
const featureFlag = resolveFeatureFlag(to);
|
||||
const installationType = resolveInstallationType(to);
|
||||
|
||||
return checkPermissions(permissions) && checkFeatureAllowed(featureFlag);
|
||||
return shouldShow(featureFlag, permissions, installationType);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -44,7 +48,6 @@ export function useSidebarContext() {
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
isOnChatwootCloud,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,17 +15,22 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
installationTypes: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { checkFeatureAllowed, checkPermissions } = usePolicy();
|
||||
const { shouldShow } = usePolicy();
|
||||
|
||||
const isFeatureAllowed = computed(() => checkFeatureAllowed(props.featureFlag));
|
||||
const hasPermission = computed(() => checkPermissions(props.permissions));
|
||||
const show = computed(() =>
|
||||
shouldShow(props.featureFlag, props.permissions, props.installationTypes)
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-root-v-if -->
|
||||
<template>
|
||||
<component :is="as" v-if="isFeatureAllowed && hasPermission">
|
||||
<component :is="as" v-if="show">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -156,13 +156,14 @@ export default {
|
||||
</slot>
|
||||
<img
|
||||
v-if="badgeSrc"
|
||||
class="source-badge"
|
||||
class="source-badge z-20"
|
||||
:style="badgeStyle"
|
||||
:src="`/integrations/channels/badges/${badgeSrc}.png`"
|
||||
alt="Badge"
|
||||
/>
|
||||
<div
|
||||
v-if="showStatusIndicator"
|
||||
class="z-20"
|
||||
:class="`source-badge user-online-status user-online-status--${status}`"
|
||||
:style="statusStyle"
|
||||
/>
|
||||
|
||||
@@ -95,9 +95,6 @@ export default {
|
||||
activeInbox: 'getSelectedInbox',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
bulkActionCheck() {
|
||||
return !this.hideThumbnail && !this.hovered && !this.selected;
|
||||
},
|
||||
chatMetadata() {
|
||||
return this.chat.meta || {};
|
||||
},
|
||||
@@ -182,10 +179,10 @@ export default {
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
onCardHover() {
|
||||
onThumbnailHover() {
|
||||
this.hovered = !this.hideThumbnail;
|
||||
},
|
||||
onCardLeave() {
|
||||
onThumbnailLeave() {
|
||||
this.hovered = false;
|
||||
},
|
||||
onSelectConversation(checked) {
|
||||
@@ -249,28 +246,36 @@ export default {
|
||||
'has-inbox-name': showInboxName,
|
||||
'conversation-selected': selected,
|
||||
}"
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<Thumbnail
|
||||
v-if="!hideThumbnail"
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="40px"
|
||||
/>
|
||||
</label>
|
||||
<Thumbnail
|
||||
v-if="bulkActionCheck"
|
||||
:src="currentContact.thumbnail"
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="40px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
|
||||
>
|
||||
@@ -400,7 +405,7 @@ export default {
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4 hover:bg-woot-100 dark:hover:bg-woot-800;
|
||||
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4;
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply m-0 cursor-pointer;
|
||||
|
||||
+5
-8
@@ -9,26 +9,23 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
meetingData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { isLoading: false, dyteAuthToken: '', isSDKMounted: false };
|
||||
},
|
||||
computed: {
|
||||
meetingLink() {
|
||||
return buildDyteURL(this.meetingData.room_name, this.dyteAuthToken);
|
||||
return buildDyteURL(this.dyteAuthToken);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async joinTheCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { data: { authResponse: { authToken } = {} } = {} } =
|
||||
await DyteAPI.addParticipantToMeeting(this.messageId);
|
||||
this.dyteAuthToken = authToken;
|
||||
const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
|
||||
this.messageId
|
||||
);
|
||||
this.dyteAuthToken = token;
|
||||
} catch (err) {
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ref, onBeforeUnmount } from 'vue';
|
||||
|
||||
export const useLiveRefresh = (callback, interval = 60000) => {
|
||||
const timeoutId = ref(null);
|
||||
|
||||
const startRefetching = () => {
|
||||
timeoutId.value = setTimeout(async () => {
|
||||
await callback();
|
||||
startRefetching();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
const stopRefetching = () => {
|
||||
if (timeoutId.value) {
|
||||
clearTimeout(timeoutId.value);
|
||||
timeoutId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRefetching();
|
||||
});
|
||||
|
||||
return {
|
||||
startRefetching,
|
||||
stopRefetching,
|
||||
};
|
||||
};
|
||||
@@ -1,20 +1,31 @@
|
||||
import { computed, unref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from 'dashboard/helper/permissionsHelper';
|
||||
import { PREMIUM_FEATURES } from 'dashboard/featureFlags';
|
||||
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
|
||||
export function usePolicy() {
|
||||
const user = useMapGetter('getCurrentUser');
|
||||
const isFeatureEnabled = useMapGetter('accounts/isFeatureEnabledonAccount');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
);
|
||||
|
||||
const { isEnterprise, enterprisePlanName } = useConfig();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const getUserPermissionsForAccount = () => {
|
||||
return getUserPermissions(user.value, accountId.value);
|
||||
};
|
||||
|
||||
const checkFeatureAllowed = featureFlag => {
|
||||
const isFeatureFlagEnabled = featureFlag => {
|
||||
if (!featureFlag) return true;
|
||||
return isFeatureEnabled.value(accountId.value, featureFlag);
|
||||
};
|
||||
@@ -25,5 +36,99 @@ export function usePolicy() {
|
||||
return hasPermissions(requiredPermissions, userPermissions);
|
||||
};
|
||||
|
||||
return { checkFeatureAllowed, checkPermissions };
|
||||
const checkInstallationType = config => {
|
||||
if (Array.isArray(config) && config.length > 0) {
|
||||
const installationCheck = {
|
||||
[INSTALLATION_TYPES.ENTERPRISE]: isEnterprise,
|
||||
[INSTALLATION_TYPES.CLOUD]: isOnChatwootCloud.value,
|
||||
[INSTALLATION_TYPES.COMMUNITY]: true,
|
||||
};
|
||||
|
||||
return config.some(type => installationCheck[type]);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isPremiumFeature = featureFlag => {
|
||||
if (!featureFlag) return true;
|
||||
return PREMIUM_FEATURES.includes(featureFlag);
|
||||
};
|
||||
|
||||
const hasPremiumEnterprise = computed(() => {
|
||||
if (isEnterprise) return enterprisePlanName !== 'community';
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const shouldShow = (featureFlag, permissions, installationTypes) => {
|
||||
const flag = unref(featureFlag);
|
||||
const perms = unref(permissions);
|
||||
const installation = unref(installationTypes);
|
||||
|
||||
// if the user does not have permissions or installation type is not supported
|
||||
// return false;
|
||||
// This supersedes everything
|
||||
if (!checkPermissions(perms)) return false;
|
||||
if (!checkInstallationType(installation)) return false;
|
||||
|
||||
if (isACustomBrandedInstance.value) {
|
||||
// if this is a custom branded instance, we just use the feature flag as a reference
|
||||
return isFeatureFlagEnabled(flag);
|
||||
}
|
||||
|
||||
// if on cloud, we should if the feature is allowed
|
||||
// or if the feature is a premium one like SLA to show a paywall
|
||||
// the paywall should be managed by the individual component
|
||||
if (isOnChatwootCloud.value) {
|
||||
return isFeatureFlagEnabled(flag) || isPremiumFeature(flag);
|
||||
}
|
||||
|
||||
if (isEnterprise) {
|
||||
// in enterprise, if the feature is premium but they don't have an enterprise plan
|
||||
// we should it anyway this is to show upsells on enterprise regardless of the feature flag
|
||||
// Feature flag is only honored if they have a premium plan
|
||||
//
|
||||
// In case they have a premium plan, the check on feature flag alone is enough
|
||||
// because the second condition will always be false
|
||||
// That means once subscribed, the feature can be disabled by the admin
|
||||
//
|
||||
// the paywall should be managed by the individual component
|
||||
return (
|
||||
isFeatureFlagEnabled(flag) ||
|
||||
(isPremiumFeature(flag) && !hasPremiumEnterprise.value)
|
||||
);
|
||||
}
|
||||
|
||||
// default to true
|
||||
return true;
|
||||
};
|
||||
|
||||
const shouldShowPaywall = featureFlag => {
|
||||
const flag = unref(featureFlag);
|
||||
if (!flag) return false;
|
||||
|
||||
if (isACustomBrandedInstance.value) {
|
||||
// custom branded instances never show paywall
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPremiumFeature(flag)) {
|
||||
if (isOnChatwootCloud.value) {
|
||||
return !isFeatureFlagEnabled(flag);
|
||||
}
|
||||
|
||||
if (isEnterprise) {
|
||||
return !hasPremiumEnterprise.value;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
checkPermissions,
|
||||
shouldShowPaywall,
|
||||
shouldShow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const INSTALLATION_TYPES = {
|
||||
CLOUD: 'cloud',
|
||||
ENTERPRISE: 'enterprise',
|
||||
COMMUNITY: 'community',
|
||||
};
|
||||
@@ -36,3 +36,11 @@ export const FEATURE_FLAGS = {
|
||||
REPORT_V4: 'report_v4',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.SLA,
|
||||
FEATURE_FLAGS.CAPTAIN,
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
];
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "ERROR_MESSAGE"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " Merge contacts",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const meta = {
|
||||
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
|
||||
permissions: ['administrator'],
|
||||
};
|
||||
|
||||
const campaignsRoutes = {
|
||||
routes: [
|
||||
@@ -19,9 +25,7 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'ongoing',
|
||||
name: 'campaigns_ongoing_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
redirect: to => {
|
||||
return { name: 'campaigns_livechat_index', params: to.params };
|
||||
},
|
||||
@@ -29,9 +33,7 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'one_off',
|
||||
name: 'campaigns_one_off_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
redirect: to => {
|
||||
return { name: 'campaigns_sms_index', params: to.params };
|
||||
},
|
||||
@@ -39,17 +41,13 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'live_chat',
|
||||
name: 'campaigns_livechat_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
component: LiveChatCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'campaigns_sms_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -78,8 +78,8 @@ onMounted(() => store.dispatch('captainAssistants/get'));
|
||||
:button-policy="['administrator']"
|
||||
:show-pagination-footer="false"
|
||||
:is-fetching="isFetching"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:is-empty="!assistants.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #emptyState>
|
||||
|
||||
@@ -72,8 +72,8 @@ onMounted(() =>
|
||||
:button-policy="['administrator']"
|
||||
:is-fetching="isFetchingAssistant || isFetching"
|
||||
:is-empty="!captainInboxes.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template v-if="!isFetchingAssistant" #headerTitle>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
@@ -12,6 +13,11 @@ export const routes = [
|
||||
name: 'captain_assistants_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -22,6 +28,11 @@ export const routes = [
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -30,6 +41,11 @@ export const routes = [
|
||||
name: 'captain_documents_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -38,6 +54,11 @@ export const routes = [
|
||||
name: 'captain_responses_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,8 +163,8 @@ onMounted(() => {
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!responses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import ContactsIndex from './pages/ContactsIndex.vue';
|
||||
import ContactManageView from './pages/ContactManageView.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
|
||||
const commonMeta = {
|
||||
featureFlag: FEATURE_FLAGS.CRM,
|
||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { getPortalRoute } from './helpers/routeHelper';
|
||||
|
||||
import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue';
|
||||
@@ -21,21 +22,21 @@ const PortalsLocalesIndexPage = () =>
|
||||
const PortalsSettingsIndexPage = () =>
|
||||
import('./pages/PortalsSettingsIndexPage.vue');
|
||||
|
||||
const meta = {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
};
|
||||
const portalRoutes = [
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/:tab?'),
|
||||
name: 'portals_articles_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/new'),
|
||||
name: 'portals_articles_new',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesNewPage,
|
||||
},
|
||||
{
|
||||
@@ -43,18 +44,14 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug'
|
||||
),
|
||||
name: 'portals_articles_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesEditPage,
|
||||
},
|
||||
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/categories'),
|
||||
name: 'portals_categories_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsCategoriesIndexPage,
|
||||
},
|
||||
{
|
||||
@@ -62,9 +59,7 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/categories/:categorySlug/articles'
|
||||
),
|
||||
name: 'portals_categories_articles_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesIndexPage,
|
||||
},
|
||||
{
|
||||
@@ -72,31 +67,26 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug'
|
||||
),
|
||||
name: 'portals_categories_articles_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesEditPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/locales'),
|
||||
name: 'portals_locales_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsLocalesIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/settings'),
|
||||
name: 'portals_settings_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsSettingsIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute('new'),
|
||||
name: 'portals_new',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'knowledge_base_manage'],
|
||||
},
|
||||
component: PortalsNew,
|
||||
@@ -105,6 +95,7 @@ const portalRoutes = [
|
||||
path: getPortalRoute(':navigationPath'),
|
||||
name: 'portals_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'knowledge_base_manage'],
|
||||
},
|
||||
component: PortalsIndex,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
@@ -21,6 +22,10 @@ export default {
|
||||
name: 'auditlogs_list',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: AuditLogsHome,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
@@ -8,6 +9,7 @@ export default {
|
||||
path: frontendURL('accounts/:accountId/settings/billing'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
@@ -21,6 +23,7 @@ export default {
|
||||
name: 'billing_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
@@ -19,6 +20,10 @@ export default {
|
||||
name: 'custom_roles_list',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: CustomRolesHome,
|
||||
|
||||
@@ -3,13 +3,11 @@ import { mapGetters } from 'vuex';
|
||||
import AgentTable from './components/overview/AgentTable.vue';
|
||||
import MetricCard from './components/overview/MetricCard.vue';
|
||||
import { OVERVIEW_METRICS } from './constants';
|
||||
import ReportHeatmap from './components/Heatmap.vue';
|
||||
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import HeatmapContainer from './components/HeatmapContainer.vue';
|
||||
export const FETCH_INTERVAL = 60000;
|
||||
|
||||
export default {
|
||||
@@ -18,7 +16,7 @@ export default {
|
||||
ReportHeader,
|
||||
AgentTable,
|
||||
MetricCard,
|
||||
ReportHeatmap,
|
||||
HeatmapContainer,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -33,7 +31,6 @@ export default {
|
||||
agents: 'agents/getAgents',
|
||||
accountConversationMetric: 'getAccountConversationMetric',
|
||||
agentConversationMetric: 'getAgentConversationMetric',
|
||||
accountConversationHeatmap: 'getAccountConversationHeatmapData',
|
||||
uiFlags: 'getOverviewUIFlags',
|
||||
}),
|
||||
agentStatusMetrics() {
|
||||
@@ -80,7 +77,6 @@ export default {
|
||||
fetchAllData() {
|
||||
this.fetchAccountConversationMetric();
|
||||
this.fetchAgentConversationMetric();
|
||||
this.fetchHeatmapData();
|
||||
},
|
||||
downloadHeatmapData() {
|
||||
let to = endOfDay(new Date());
|
||||
@@ -89,33 +85,7 @@ export default {
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
},
|
||||
fetchHeatmapData() {
|
||||
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the data for the last 6 days won't ever change,
|
||||
// so there's no need to fetch it again
|
||||
// but we can write some logic to check if the data is already there
|
||||
// if it is there, we can refetch data only for today all over again
|
||||
// and reconcile it with the rest of the data
|
||||
// this will reduce the load on the server doing number crunching
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, 6));
|
||||
|
||||
if (this.accountConversationHeatmap.length) {
|
||||
to = endOfDay(new Date());
|
||||
from = startOfDay(to);
|
||||
}
|
||||
|
||||
this.$store.dispatch('fetchAccountConversationHeatmap', {
|
||||
metric: 'conversations_count',
|
||||
from: getUnixTime(from),
|
||||
to: getUnixTime(to),
|
||||
groupBy: 'hour',
|
||||
businessHours: false,
|
||||
});
|
||||
},
|
||||
fetchAccountConversationMetric() {
|
||||
this.$store.dispatch('fetchAccountConversationMetric', {
|
||||
type: 'account',
|
||||
@@ -180,25 +150,7 @@ export default {
|
||||
</MetricCard>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||
<template #control>
|
||||
<woot-button
|
||||
icon="arrow-download"
|
||||
size="small"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
@click="downloadHeatmapData"
|
||||
>
|
||||
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
<ReportHeatmap
|
||||
:heat-data="accountConversationHeatmap"
|
||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
<HeatmapContainer />
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||
<AgentTable
|
||||
|
||||
@@ -112,22 +112,28 @@ export default {
|
||||
};
|
||||
},
|
||||
getChartOptions(metric) {
|
||||
let tooltips = {};
|
||||
const options = {
|
||||
scales: METRIC_CHART[metric.KEY].scales,
|
||||
};
|
||||
|
||||
// Only add tooltip configuration for time-based metrics
|
||||
if (this.isAverageMetricType(metric.KEY)) {
|
||||
tooltips.callbacks = {
|
||||
label: tooltipItem => {
|
||||
return this.$t(metric.TOOLTIP_TEXT, {
|
||||
metricValue: formatTime(tooltipItem.yLabel),
|
||||
conversationCount:
|
||||
this.accountReport.data[metric.KEY][tooltipItem.index].count,
|
||||
});
|
||||
options.plugins = {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ({ raw, dataIndex }) => {
|
||||
return this.$t(metric.TOOLTIP_TEXT, {
|
||||
metricValue: formatTime(raw || 0),
|
||||
conversationCount:
|
||||
this.accountReport.data[metric.KEY][dataIndex]?.count || 0,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
scales: METRIC_CHART[metric.KEY].scales,
|
||||
tooltips: tooltips,
|
||||
};
|
||||
|
||||
return options;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
heatData: {
|
||||
heatmapData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
numberOfRows: {
|
||||
type: Number,
|
||||
default: 7,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -21,11 +25,11 @@ const props = defineProps({
|
||||
});
|
||||
const { t } = useI18n();
|
||||
const processedData = computed(() => {
|
||||
return groupHeatmapByDay(props.heatData);
|
||||
return groupHeatmapByDay(props.heatmapData);
|
||||
});
|
||||
|
||||
const quantileRange = computed(() => {
|
||||
const flattendedData = props.heatData.map(data => data.value);
|
||||
const flattendedData = props.heatmapData.map(data => data.value);
|
||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||
});
|
||||
|
||||
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
|
||||
<template v-if="isLoading">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="ii in 7"
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="ii in 7"
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import MetricCard from './overview/MetricCard.vue';
|
||||
import ReportHeatmap from './Heatmap.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||
const accountConversationHeatmap = useMapGetter(
|
||||
'getAccountConversationHeatmapData'
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||
value: 29,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedDays = ref(6);
|
||||
|
||||
const selectedDayFilter = computed(() =>
|
||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||
);
|
||||
|
||||
const downloadHeatmapData = () => {
|
||||
const to = endOfDay(new Date());
|
||||
store.dispatch('downloadAccountConversationHeatmap', {
|
||||
daysBefore: selectedDays.value,
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
};
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const fetchHeatmapData = () => {
|
||||
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||
|
||||
store.dispatch('fetchAccountConversationHeatmap', {
|
||||
metric: 'conversations_count',
|
||||
from: getUnixTime(from),
|
||||
to: getUnixTime(to),
|
||||
groupBy: 'hour',
|
||||
businessHours: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
selectedDays.value = value;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||
|
||||
onMounted(() => {
|
||||
fetchHeatmapData();
|
||||
startRefetching();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||
<template #control>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedDayFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="downloadHeatmapData"
|
||||
/>
|
||||
</template>
|
||||
<ReportHeatmap
|
||||
:heatmap-data="accountConversationHeatmap"
|
||||
:number-of-rows="selectedDays + 1"
|
||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</template>
|
||||
+14
-22
@@ -1,26 +1,20 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
name: 'MetricCard',
|
||||
components: {
|
||||
Spinner,
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
props: {
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,9 +40,7 @@ export default {
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
|
||||
>
|
||||
<div class="flex flex-row items-center justify-end gap-2">
|
||||
<slot name="control" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -22,37 +22,34 @@ import BotReports from './BotReports.vue';
|
||||
import LiveReports from './LiveReports.vue';
|
||||
import SLAReports from './SLAReports.vue';
|
||||
|
||||
const meta = {
|
||||
featureFlag: FEATURE_FLAGS.REPORTS,
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
};
|
||||
|
||||
const oldReportRoutes = [
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: AgentReports,
|
||||
},
|
||||
{
|
||||
path: 'inboxes',
|
||||
name: 'inbox_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: InboxReports,
|
||||
},
|
||||
{
|
||||
path: 'label',
|
||||
name: 'label_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: LabelReports,
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
name: 'team_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: TeamReports,
|
||||
},
|
||||
];
|
||||
@@ -124,17 +121,13 @@ export default {
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'account_overview_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: LiveReports,
|
||||
},
|
||||
{
|
||||
path: 'conversation',
|
||||
name: 'conversation_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: Index,
|
||||
},
|
||||
...oldReportRoutes,
|
||||
@@ -142,26 +135,19 @@ export default {
|
||||
{
|
||||
path: 'sla',
|
||||
name: 'sla_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
},
|
||||
meta,
|
||||
component: SLAReports,
|
||||
},
|
||||
{
|
||||
path: 'csat',
|
||||
name: 'csat_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: CsatResponses,
|
||||
},
|
||||
{
|
||||
path: 'bot',
|
||||
name: 'bot_reports',
|
||||
meta: {
|
||||
permissions: ['administrator', 'report_manage'],
|
||||
},
|
||||
meta,
|
||||
component: BotReports,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
const meta = {
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -14,10 +21,7 @@ export default {
|
||||
{
|
||||
path: '',
|
||||
name: 'sla_wrapper',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
redirect: to => {
|
||||
return { name: 'sla_list', params: to.params };
|
||||
},
|
||||
@@ -25,10 +29,7 @@ export default {
|
||||
{
|
||||
path: 'list',
|
||||
name: 'sla_list',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.SLA,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
component: Index,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,10 +4,7 @@ import Report from '../../api/reports';
|
||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
import {
|
||||
reconcileHeatmapData,
|
||||
clampDataBetweenTimeline,
|
||||
} from 'shared/helpers/ReportsDataHelper';
|
||||
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
|
||||
|
||||
const state = {
|
||||
fetchingStatus: false,
|
||||
@@ -114,11 +111,6 @@ export const actions = {
|
||||
let { data } = heatmapData;
|
||||
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||
|
||||
data = reconcileHeatmapData(
|
||||
data,
|
||||
state.overview.accountConversationHeatmap
|
||||
);
|
||||
|
||||
commit(types.default.SET_HEATMAP_DATA, data);
|
||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||
});
|
||||
@@ -234,7 +226,7 @@ export const actions = {
|
||||
});
|
||||
},
|
||||
downloadAccountConversationHeatmap(_, reportObj) {
|
||||
Report.getConversationTrafficCSV()
|
||||
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||
.then(response => {
|
||||
downloadCsvFile(
|
||||
generateFileName({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.in/meeting/stage/';
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
|
||||
|
||||
export const buildDyteURL = (roomName, dyteAuthToken) => {
|
||||
return `${DYTE_MEETING_LINK}${roomName}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
export const buildDyteURL = dyteAuthToken => {
|
||||
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,6 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
meetingData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { isLoading: false, dyteAuthToken: '', isSDKMounted: false };
|
||||
@@ -28,18 +24,18 @@ export default {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
meetingLink() {
|
||||
return buildDyteURL(this.meetingData.room_name, this.dyteAuthToken);
|
||||
return buildDyteURL(this.dyteAuthToken);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async joinTheCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { data: { authResponse: { authToken = '' } = {} } = {} } =
|
||||
await IntegrationAPIClient.addParticipantToDyteMeeting(
|
||||
this.messageId
|
||||
);
|
||||
this.dyteAuthToken = authToken;
|
||||
const response = await IntegrationAPIClient.addParticipantToDyteMeeting(
|
||||
this.messageId
|
||||
);
|
||||
const { data: { token } = {} } = response;
|
||||
this.dyteAuthToken = token;
|
||||
} catch (error) {
|
||||
// Ignore Error for now
|
||||
} finally {
|
||||
|
||||
@@ -37,6 +37,8 @@ class Campaign < ApplicationRecord
|
||||
validate :validate_campaign_inbox
|
||||
validate :validate_url
|
||||
validate :prevent_completed_campaign_from_update, on: :update
|
||||
validate :sender_must_belong_to_account
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :sender, class_name: 'User', optional: true
|
||||
@@ -90,6 +92,14 @@ class Campaign < ApplicationRecord
|
||||
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
|
||||
end
|
||||
|
||||
def sender_must_belong_to_account
|
||||
return unless sender
|
||||
|
||||
return if account.users.exists?(id: sender.id)
|
||||
|
||||
errors.add(:sender_id, 'must belong to the same account as the campaign')
|
||||
end
|
||||
|
||||
def prevent_completed_campaign_from_update
|
||||
errors.add :status, 'The campaign is already completed' if !campaign_status_changed? && completed?
|
||||
end
|
||||
|
||||
+12
-6
@@ -82,14 +82,20 @@ class Inbox < ApplicationRecord
|
||||
|
||||
scope :order_by_name, -> { order('lower(name) ASC') }
|
||||
|
||||
def add_member(user_id)
|
||||
member = inbox_members.new(user_id: user_id)
|
||||
member.save!
|
||||
# Adds multiple members to the inbox
|
||||
# @param user_ids [Array<Integer>] Array of user IDs to add as members
|
||||
# @return [void]
|
||||
def add_members(user_ids)
|
||||
inbox_members.create!(user_ids.map { |user_id| { user_id: user_id } })
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
def remove_member(user_id)
|
||||
member = inbox_members.find_by!(user_id: user_id)
|
||||
member.try(:destroy)
|
||||
# Removes multiple members from the inbox
|
||||
# @param user_ids [Array<Integer>] Array of user IDs to remove
|
||||
# @return [void]
|
||||
def remove_members(user_ids)
|
||||
inbox_members.where(user_id: user_ids).destroy_all
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
def facebook?
|
||||
|
||||
+16
-4
@@ -31,12 +31,24 @@ class Team < ApplicationRecord
|
||||
self.name = name.downcase if attribute_present?('name')
|
||||
end
|
||||
|
||||
def add_member(user_id)
|
||||
team_members.find_or_create_by(user_id: user_id)&.user
|
||||
# Adds multiple members to the team
|
||||
# @param user_ids [Array<Integer>] Array of user IDs to add as members
|
||||
# @return [Array<User>] Array of newly added members
|
||||
def add_members(user_ids)
|
||||
team_members_to_create = user_ids.map { |user_id| { user_id: user_id } }
|
||||
created_members = team_members.create(team_members_to_create)
|
||||
added_users = created_members.filter_map(&:user)
|
||||
|
||||
update_account_cache
|
||||
added_users
|
||||
end
|
||||
|
||||
def remove_member(user_id)
|
||||
team_members.find_by(user_id: user_id)&.destroy!
|
||||
# Removes multiple members from the team
|
||||
# @param user_ids [Array<Integer>] Array of user IDs to remove
|
||||
# @return [void]
|
||||
def remove_members(user_ids)
|
||||
team_members.where(user_id: user_ids).destroy_all
|
||||
update_account_cache
|
||||
end
|
||||
|
||||
def messages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
json.payload do
|
||||
json.array! @contactable_inboxes do |contactable_inbox|
|
||||
json.inbox do
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: contactable_inbox[:inbox]
|
||||
json.partial! 'api/v1/models/inbox_slim', formats: [:json], resource: contactable_inbox[:inbox]
|
||||
end
|
||||
json.source_id contactable_inbox[:source_id]
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
json.source_id resource.source_id
|
||||
json.inbox do
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: resource.inbox
|
||||
json.partial! 'api/v1/models/inbox_slim', formats: [:json], resource: resource.inbox
|
||||
end
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
json.id resource.id
|
||||
json.avatar_url resource.try(:avatar_url)
|
||||
json.channel_id resource.channel_id
|
||||
json.name resource.name
|
||||
json.channel_type resource.channel_type
|
||||
json.provider resource.channel.try(:provider)
|
||||
json.email resource.channel.try(:email) if resource.email?
|
||||
@@ -2,17 +2,33 @@
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field feature-container">
|
||||
<% SuperAdmin::AccountFeaturesHelper.filtered_features(field.data).each do |key, val| %>
|
||||
<div class='feature-cell'>
|
||||
<% is_premium = SuperAdmin::AccountFeaturesHelper.account_premium_features.include? key %>
|
||||
<% if is_premium %>
|
||||
<span class='icon-container'>
|
||||
<svg class="inline" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<% end %>
|
||||
<span><%= key %></span>
|
||||
<% should_disable = is_premium && ChatwootHub.pricing_plan == 'community' %>
|
||||
<span class='value-container'><%= check_box "enabled_features", "feature_#{key}", { checked: val, disabled: should_disable }, true, false %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% regular_features, premium_features = SuperAdmin::AccountFeaturesHelper.filtered_features(field.data).partition { |key_array, _val| !SuperAdmin::AccountFeaturesHelper.account_premium_features.include?(key_array.first) } %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% regular_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container">
|
||||
<span class="text-sm text-slate-700"><%= display_name %></span>
|
||||
<span><%= check_box "enabled_features", "feature_#{feature_key}", { checked: val, class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" }, true, false %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 boshadow-sm outline outline-1 outline-n-container">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% premium_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-500">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<span class="text-sm text-slate-700"><%= display_name %></span>
|
||||
</div>
|
||||
<% should_disable = ChatwootHub.pricing_plan == 'community' %>
|
||||
<span><%= check_box "enabled_features", "feature_#{feature_key}", { checked: val, disabled: should_disable, class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" }, true, false %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
<div class='feature-container'>
|
||||
<% SuperAdmin::AccountFeaturesHelper.filtered_features(field.data).each do |key, val| %>
|
||||
<div class='feature-cell'>
|
||||
<% if SuperAdmin::AccountFeaturesHelper.account_premium_features.include? key %>
|
||||
<span class='icon-container'>
|
||||
<svg class="inline" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<% end %>
|
||||
<span><%= key %></span>
|
||||
<span class='value-container'><%= val.present? ? '✅' : '❌' %> </span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<% regular_features, premium_features = SuperAdmin::AccountFeaturesHelper.partition_features(field.data) %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% regular_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
|
||||
<span class="text-sm text-n-slate-12"><%= display_name %></span>
|
||||
<span class="<%= val.present? ? 'bg-green-400 text-white': 'bg-slate-50 text-slate-800' %> rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="12" height="12"><use xlink:href="#icon-tick-line" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 border-t border-n-weak">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% premium_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-n-amber-3 text-n-amber-12 rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="12" height="12"><use xlink:href="#icon-lock-line" /></svg>
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12"><%= display_name %></span>
|
||||
</div>
|
||||
<span class="<%= val.present? ? 'bg-green-400 text-white': 'bg-slate-50 text-slate-800' %> rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="12" height="12"><use xlink:href="#icon-tick-line" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<hr/>
|
||||
<%= form_for([:reset_cache, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
|
||||
<div class="form-actions">
|
||||
<p>This will clear the IndexedDB cache keys from redis. <br>The next load will fetch the data from backend.</p>
|
||||
<p class="pb-3">This will clear the IndexedDB cache keys from redis. <br>The next load will fetch the data from backend.</p>
|
||||
<%= f.submit 'Reset Frontend Cache' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<%= form_for([:seed, namespace, page.resource], method: :post, html: { class: "form" }) do |f| %>
|
||||
|
||||
<div class="form-actions">
|
||||
<div><p> Click the button to generate seed data into this account for demos.</p>
|
||||
<p class="text-color-red">Note: This will clear all the existing data in this account.</p>
|
||||
</div>
|
||||
<%= f.submit 'Generate Seed Data' %>
|
||||
<div class="pb-3">
|
||||
<p>Click the button to generate seed data into this account for demos.</p>
|
||||
<p class="text-color-red">Note: This will clear all the existing data in this account.</p>
|
||||
</div>
|
||||
<%= f.submit 'Generate Seed Data' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -13,89 +13,134 @@ as well as a link to its edit page.
|
||||
as well as helpers for describing how each attribute of the resource
|
||||
should be displayed.
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show
|
||||
%>
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Show %>
|
||||
|
||||
<% content_for(:title) { t("administrate.actions.show_resource", name: page.page_title) } %>
|
||||
<% content_for(:title) do
|
||||
t("administrate.actions.show_resource", name: page.page_title)
|
||||
end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<%= link_to(
|
||||
"Edit",
|
||||
[:edit, namespace, page.resource],
|
||||
class: "button",
|
||||
) if accessible_action?(page.resource, :edit) %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="main-content__body">
|
||||
<dl>
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<% attributes.each do |attribute| %>
|
||||
<dt class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</dt>
|
||||
|
||||
<dd class="attribute-data attribute-data--<%=attribute.html_class%>"
|
||||
><%= render_field attribute, page: page %></dd>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="main-content__body">
|
||||
<% account_user_page = Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
|
||||
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
|
||||
<% if account_user_page.resource.errors.any? %>
|
||||
<div id="error_explanation">
|
||||
<h2>
|
||||
<%= t(
|
||||
"administrate.form.errors",
|
||||
pluralized_errors: pluralize(account_user_page.resource.errors.count, t("administrate.form.error")),
|
||||
resource_name: display_resource_name(account_user_page.resource_name)
|
||||
) %>
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
<% account_user_page.resource.errors.full_messages.each do |message| %>
|
||||
<li class="flash-error"><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<div>
|
||||
<%= if accessible_action?(page.resource, :edit)
|
||||
link_to("Edit", [:edit, namespace, page.resource], class: "button")
|
||||
end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field('account_id', value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div class="field-unit field-unit--<%= attribute.html_class %> field-unit--<%= requireness(attribute) %>">
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<section class="main-content__body">
|
||||
<div class="pr-16">
|
||||
<% page.attributes.each do |title, attributes| %>
|
||||
<% if title.present? && title == 'all_features' %>
|
||||
<% regular_features, premium_features =
|
||||
attributes.partition do |attr|
|
||||
!SuperAdmin::AccountFeaturesHelper.account_premium_features.include?(
|
||||
attr.data.keys.first,
|
||||
)
|
||||
end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% regular_features.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 border-n-weak">
|
||||
|
||||
<div class="space-y-4">
|
||||
<% premium_features.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<fieldset class="<%= "field-unit--nested" if title.present? %>">
|
||||
<% if title.present? %>
|
||||
<legend><%= t "helpers.label.#{page.resource_name}.#{title}", default: title %></legend>
|
||||
<% end %>
|
||||
|
||||
<% attributes.each do |attribute| %>
|
||||
<div class="attribute-label" id="<%= attribute.name %>">
|
||||
<%= t(
|
||||
"helpers.label.#{resource_name}.#{attribute.name}",
|
||||
default: page.resource.class.human_attribute_name(attribute.name),
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="attribute-data attribute-data--<%=attribute.html_class%>"><%= render_field attribute, page: page %></div>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="main-content__body">
|
||||
<% account_user_page =
|
||||
Administrate::Page::Form.new(AccountUserDashboard.new, AccountUser.new) %>
|
||||
<%= form_for([namespace, account_user_page.resource], html: { class: "form" }) do |f| %>
|
||||
<% if account_user_page.resource.errors.any? %>
|
||||
<div id="error_explanation">
|
||||
<h2>
|
||||
<%= t(
|
||||
"administrate.form.errors",
|
||||
pluralized_errors:
|
||||
pluralize(
|
||||
account_user_page.resource.errors.count,
|
||||
t("administrate.form.error"),
|
||||
),
|
||||
resource_name: display_resource_name(account_user_page.resource_name),
|
||||
) %>
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
<% account_user_page.resource.errors.full_messages.each do |message| %>
|
||||
<li class="flash-error"><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% account_user_page.attributes.each do |title, attributes| -%>
|
||||
<% attributes.each do |attribute| %>
|
||||
<% if attribute.name == "account" %>
|
||||
<%= f.hidden_field("account_id", value: page.resource.id) %>
|
||||
<% else %>
|
||||
<div
|
||||
class="
|
||||
field-unit field-unit--<%= attribute.html_class %>
|
||||
field-unit--<%= requireness(attribute) %>
|
||||
"
|
||||
>
|
||||
<%= render_field attribute, f: f %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
<div class="form-actions">
|
||||
<%= f.submit %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
|
||||
<div class="form-actions">
|
||||
<%= f.submit %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
<%= render partial: "seed_data", locals: { page: page } %>
|
||||
|
||||
<%= render partial: "seed_data", locals: {page: page} %>
|
||||
|
||||
<%= render partial: "reset_cache", locals: {page: page} %>
|
||||
<%= render partial: "reset_cache", locals: { page: page } %>
|
||||
|
||||
@@ -1,49 +1,48 @@
|
||||
<% content_for(:title) do %>
|
||||
Settings
|
||||
<% end %>
|
||||
<header class="flex mx-8 py-4 items-center border-b border-solid border-slate-100" role="banner">
|
||||
<div class="border border-solid border-slate-100 text-slate-700 mr-4 p-2 rounded-full">
|
||||
<header class="flex px-8 py-4 items-center border-b border-n-weak" role="banner">
|
||||
<div class="border border-n-weak mr-4 p-2 rounded-full">
|
||||
<svg width="24" height="24"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
</div>
|
||||
<div class="flex flex-col h-14 justify-center">
|
||||
<h1 class="text-base font-medium leading-6 text-slate-900" id="page-title">
|
||||
<h1 class="text-base font-medium leading-6 text-n-slate-12" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
<p class="text-sm font-normal leading-5 text-slate-500 m-0">Update your instance settings, access billing portal</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="main-content__body">
|
||||
|
||||
<section class="main-content__body px-8">
|
||||
<% if Redis::Alfred.get(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING) %>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-5" role="alert">
|
||||
<strong class="font-bold">Alert!</strong>
|
||||
<span class="block sm:inline">Unauthorized premium changes detected in Chatwoot. To keep using them, please upgrade your plan.
|
||||
<span class="block sm:inline">Unauthorized premium changes detected in Chatwoot. To keep using them, please upgrade your plan.
|
||||
Contact for help :</span><span class="inline rounded-full bg-red-200 px-2 text-white ml-2">sales@chatwoot.com</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white py-2 px-3">
|
||||
<div class="bg-white py-2 px-4 xl:px-0">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="h-5 leading-5 text-slate-900 font-medium">Current plan</h2>
|
||||
<h2 class="h-5 leading-5 text-n-slate-12 font-medium">Current plan</h2>
|
||||
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
|
||||
<span>Refresh</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-slate-600 mt-1"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
<p class="text-n-slate-11 mt-1"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
<div class="flex items-center mt-6">
|
||||
<h4 class="text-sm font-medium leading-5 h-5 text-slate-900 mr-4">Installation Identifier</h4>
|
||||
<span class="text-sm leading-5 h-5 text-slate-600"><%= ChatwootHub.installation_identifier %></span>
|
||||
<h4 class="text-sm font-medium leading-5 h-5 text-n-slate-12 mr-4">Installation Identifier</h4>
|
||||
<span class="text-sm leading-5 h-5 text-n-slate-11"><%= ChatwootHub.installation_identifier %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex p-4 border border-solid border-slate-100 rounded-lg mt-8 items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg mt-8 items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex flex-col flex-grow gap-1">
|
||||
<h2 class="h-5 leading-5 text-slate-900 text-sm font-medium">Current plan</h2>
|
||||
<p class="text-slate-600 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
|
||||
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
</div>
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:bg-slate-100 hover:text-slate-800 border border-solid border-slate-100 rounded text-slate-700 font-medium p-2">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="px-1">Manage</span>
|
||||
</button>
|
||||
@@ -59,30 +58,30 @@
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class="flex p-4 border border-solid border-slate-100 rounded-lg mt-4 items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg mt-4 items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex flex-col flex-grow gap-1">
|
||||
<h2 class="h-5 leading-5 text-slate-900 text-sm font-medium">Need help?</h2>
|
||||
<p class="text-slate-600 m-0 text-sm">Do you face any issues? We are here to help.</p>
|
||||
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Need help?</h2>
|
||||
<p class="text-n-slate-11 m-0 text-sm">Do you face any issues? We are here to help.</p>
|
||||
</div>
|
||||
<a href="https://discord.gg/cJXdrwS" target="_blank">
|
||||
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-violet-100 hover:text-violet-200 border border-solid border-violet-600 rounded font-medium p-2">
|
||||
<button class="flex mt-4 md:mt-0 gap-1 items-center bg-transparent shadow-sm h-9 bg-violet-500 hover:bg-violet-600 text-white border border-solid border-violet-600 rounded font-medium p-2">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Community Support</span>
|
||||
</button>
|
||||
</a>
|
||||
<% if ChatwootHub.pricing_plan !='community' %>
|
||||
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:bg-slate-100 hover:text-slate-800 border border-solid border-slate-100 rounded text-slate-700 font-medium p-2" onclick="window.$chatwoot.toggle('open')">
|
||||
<button class="ml-4 flex gap-1 items-center bg-transparent h-9 hover:text-n-slate-12 hover:bg-slate-50 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2" onclick="window.$chatwoot.toggle('open')">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Chat Support</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="mt-10 py-4">
|
||||
<h3 class="h-5 leading-5 text-slate-900 font-medium text-base">Features</h3>
|
||||
<h3 class="h-5 leading-5 text-n-slate-12 font-medium text-base">Features</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-x-2 gap-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-2 gap-y-3">
|
||||
<% SuperAdmin::FeaturesHelper.available_features.each do |feature, attrs| %>
|
||||
<div class="group border-slate-100 border p-4 rounded-lg relative shadow-sm">
|
||||
<div class="group outline outline-1 outline-n-container p-4 rounded-lg relative shadow-sm">
|
||||
<span class="<%= attrs[:enabled] ? 'bg-green-400 text-white': 'bg-slate-300 text-slate-800' %> absolute rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="14" height="14"><use xlink:href="<%= attrs[:enabled] ? '#icon-tick-line': '#icon-lock-line' %>" /></svg>
|
||||
</span>
|
||||
@@ -91,7 +90,7 @@
|
||||
</div>
|
||||
<% if !attrs[:enabled] %>
|
||||
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-slate-900 border border-solid border-slate-100 rounded text-slate-600 font-medium p-2">
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Upgrade now</span>
|
||||
</a>
|
||||
@@ -99,9 +98,9 @@
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between mb-1.5 mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-slate-900 font-medium"><%= attrs[:name] %></h3>
|
||||
<h3 class="text-n-slate-12 font-medium"><%= attrs[:name] %></h3>
|
||||
<% if attrs[:enterprise] %>
|
||||
<span class="px-2 h-4 leading-4 rounded-xl text-green-800 font-medium bg-green-100/70 text-xxs">EE</span>
|
||||
<span class="px-2 h-4 leading-4 rounded-xl text-green-800 font-medium bg-green-100/70 text-xxs">EE</span>
|
||||
<% end %>
|
||||
<% if attrs[:config_key].present? && attrs[:enabled] %>
|
||||
<a class="inline-flex items-center justify-center h-5 w-5 hover:bg-slate-50 rounded-sm" href="/super_admin/app_config?config=<%= attrs[:config_key] %>">
|
||||
@@ -110,7 +109,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-600 mb-0"><%= attrs[:description] %></p>
|
||||
<p class="text-n-slate-11 mb-0"><%= attrs[:description] %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.0.1'
|
||||
version: '4.0.2'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+55
-1
@@ -1,102 +1,156 @@
|
||||
# DO NOT change the order of features EVER
|
||||
############################################
|
||||
# name: the name to be used internally in the code
|
||||
# display_name: the name to be used in the UI
|
||||
# enabled: whether the feature is enabled by default
|
||||
# help_url: the url to the help center article
|
||||
# chatwoot_internal: whether the feature is internal to Chatwoot and should not be shown in the UI for other self hosted installations
|
||||
# deprecated: purpose of feature flag is done, no need to show it in the UI anymore
|
||||
- name: inbound_emails
|
||||
display_name: Inbound Emails
|
||||
enabled: true
|
||||
- name: channel_email
|
||||
display_name: Email Channel
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/email
|
||||
- name: channel_facebook
|
||||
display_name: Facebook Channel
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/fb
|
||||
- name: channel_twitter
|
||||
display_name: Twitter Channel
|
||||
enabled: true
|
||||
deprecated: true
|
||||
- name: ip_lookup
|
||||
display_name: IP Lookup
|
||||
enabled: false
|
||||
- name: disable_branding
|
||||
display_name: Disable Branding
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: email_continuity_on_api_channel
|
||||
display_name: Email Continuity on API Channel
|
||||
enabled: false
|
||||
- name: help_center
|
||||
display_name: Help Center
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/help-center
|
||||
- name: agent_bots
|
||||
display_name: Agent Bots
|
||||
enabled: false
|
||||
help_url: https://chwt.app/hc/agent-bots
|
||||
- name: macros
|
||||
display_name: Macros
|
||||
enabled: true
|
||||
- name: agent_management
|
||||
display_name: Agent Management
|
||||
enabled: true
|
||||
- name: team_management
|
||||
display_name: Team Management
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/teams
|
||||
- name: inbox_management
|
||||
display_name: Inbox Management
|
||||
enabled: true
|
||||
- name: labels
|
||||
display_name: Labels
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/labels
|
||||
- name: custom_attributes
|
||||
display_name: Custom Attributes
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/custom-attributes
|
||||
- name: automations
|
||||
display_name: Automations
|
||||
enabled: true
|
||||
- name: canned_responses
|
||||
display_name: Canned Responses
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/canned
|
||||
- name: integrations
|
||||
display_name: Integrations
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/integrations
|
||||
- name: voice_recorder
|
||||
display_name: Voice Recorder
|
||||
enabled: true
|
||||
- name: mobile_v2
|
||||
display_name: Mobile App V2
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: channel_website
|
||||
display_name: Website Channel
|
||||
enabled: true
|
||||
- name: campaigns
|
||||
display_name: Campaigns
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/campaigns
|
||||
- name: reports
|
||||
display_name: Reports
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/reports
|
||||
- name: crm
|
||||
display_name: CRM
|
||||
enabled: true
|
||||
- name: auto_resolve_conversations
|
||||
display_name: Auto Resolve Conversations
|
||||
enabled: true
|
||||
- name: custom_reply_email
|
||||
display_name: Custom Reply Email
|
||||
enabled: false
|
||||
- name: custom_reply_domain
|
||||
display_name: Custom Reply Domain
|
||||
enabled: false
|
||||
- name: audit_logs
|
||||
display_name: Audit Logs
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: response_bot
|
||||
display_name: Response Bot
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
deprecated: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
help_url: https://chwt.app/hc/reply-to
|
||||
deprecated: true
|
||||
- name: insert_article_in_reply
|
||||
display_name: Insert Article in Reply
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: inbox_view
|
||||
display_name: Inbox View
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: sla
|
||||
display_name: SLA
|
||||
enabled: false
|
||||
premium: true
|
||||
help_url: https://chwt.app/hc/sla
|
||||
- name: help_center_embedding_search
|
||||
display_name: Help Center Embedding Search
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: linear_integration
|
||||
display_name: Linear Integration
|
||||
enabled: false
|
||||
- name: captain_integration
|
||||
display_name: Captain
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: custom_roles
|
||||
display_name: Custom Roles
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: chatwoot_v4
|
||||
display_name: Chatwoot V4
|
||||
enabled: false
|
||||
- name: report_v4
|
||||
display_name: Report V4
|
||||
enabled: false
|
||||
- name: contact_chatwoot_support_team
|
||||
display_name: Contact Chatwoot Support Team
|
||||
enabled: true
|
||||
chatwoot_internal: true
|
||||
|
||||
@@ -2,7 +2,7 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').present?
|
||||
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
perform_firecrawl_crawl(document)
|
||||
else
|
||||
perform_simple_crawl(document)
|
||||
|
||||
+11
-17
@@ -1,9 +1,10 @@
|
||||
class Dyte
|
||||
BASE_URL = 'https://api.cluster.dyte.in/v1'.freeze
|
||||
BASE_URL = 'https://api.dyte.io/v2'.freeze
|
||||
API_KEY_HEADER = 'Authorization'.freeze
|
||||
PRESET_NAME = 'group_call_host'.freeze
|
||||
|
||||
def initialize(organization_id, api_key)
|
||||
@api_key = api_key
|
||||
@api_key = Base64.strict_encode64("#{organization_id}:#{api_key}")
|
||||
@organization_id = organization_id
|
||||
|
||||
raise ArgumentError, 'Missing Credentials' if @api_key.blank? || @organization_id.blank?
|
||||
@@ -11,15 +12,9 @@ class Dyte
|
||||
|
||||
def create_a_meeting(title)
|
||||
payload = {
|
||||
'title': title,
|
||||
'authorization': {
|
||||
'waitingRoom': false,
|
||||
'closed': false
|
||||
},
|
||||
'recordOnStart': false,
|
||||
'liveStreamOnStart': false
|
||||
'title': title
|
||||
}
|
||||
path = "organizations/#{@organization_id}/meeting"
|
||||
path = 'meetings'
|
||||
response = post(path, payload)
|
||||
process_response(response)
|
||||
end
|
||||
@@ -28,13 +23,12 @@ class Dyte
|
||||
raise ArgumentError, 'Missing information' if meeting_id.blank? || client_id.blank? || name.blank? || avatar_url.blank?
|
||||
|
||||
payload = {
|
||||
'clientSpecificId': client_id.to_s,
|
||||
'userDetails': {
|
||||
'name': name,
|
||||
'picture': avatar_url
|
||||
}
|
||||
'custom_participant_id': client_id.to_s,
|
||||
'name': name,
|
||||
'picture': avatar_url,
|
||||
'preset_name': PRESET_NAME
|
||||
}
|
||||
path = "organizations/#{@organization_id}/meetings/#{meeting_id}/participant"
|
||||
path = "meetings/#{meeting_id}/participants"
|
||||
response = post(path, payload)
|
||||
process_response(response)
|
||||
end
|
||||
@@ -50,7 +44,7 @@ class Dyte
|
||||
def post(path, payload)
|
||||
HTTParty.post(
|
||||
"#{BASE_URL}/#{path}", {
|
||||
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
|
||||
headers: { API_KEY_HEADER => "Basic #{@api_key}", 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ class Integrations::Dyte::ProcessorService
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
meeting = response['meeting']
|
||||
meeting = response
|
||||
message = create_a_dyte_integration_message(meeting, title, agent)
|
||||
message.push_event_data
|
||||
end
|
||||
@@ -29,8 +29,7 @@ class Integrations::Dyte::ProcessorService
|
||||
content_attributes: {
|
||||
type: 'dyte',
|
||||
data: {
|
||||
meeting_id: meeting['id'],
|
||||
room_name: meeting['roomName']
|
||||
meeting_id: meeting['id']
|
||||
}
|
||||
},
|
||||
sender: agent
|
||||
|
||||
@@ -119,12 +119,14 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
def upload_file
|
||||
return unless message.attachments.first.with_attached_file?
|
||||
|
||||
result = slack_client.files_upload({
|
||||
channels: hook.reference_id,
|
||||
result = slack_client.files_upload_v2(
|
||||
filename: message.attachments.first.file.filename,
|
||||
content: message.attachments.first.file.download,
|
||||
initial_comment: 'Attached File!',
|
||||
thread_ts: conversation.identifier
|
||||
}.merge(file_information))
|
||||
Rails.logger.info(result)
|
||||
thread_ts: conversation.identifier,
|
||||
channel_id: hook.reference_id
|
||||
)
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
end
|
||||
|
||||
def file_type
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -66,12 +66,13 @@
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"date-fns": "2.21.1",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"dompurify": "3.1.6",
|
||||
"dompurify": "3.2.4",
|
||||
"flag-icons": "^7.2.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
"idb": "^8.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lettersanitizer": "^1.0.6",
|
||||
"libphonenumber-js": "^1.11.9",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
@@ -92,7 +93,7 @@
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
"vue-i18n": "9.14.2",
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-multiselect": "3.1.0",
|
||||
"vue-router": "~4.4.5",
|
||||
"vue-upload-component": "^3.1.17",
|
||||
|
||||
Generated
+28
-23
@@ -119,8 +119,8 @@ importers:
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.8(date-fns@2.21.1)
|
||||
dompurify:
|
||||
specifier: 3.1.6
|
||||
version: 3.1.6
|
||||
specifier: 3.2.4
|
||||
version: 3.2.4
|
||||
flag-icons:
|
||||
specifier: ^7.2.3
|
||||
version: 7.2.3
|
||||
@@ -136,6 +136,9 @@ importers:
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
lettersanitizer:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
libphonenumber-js:
|
||||
specifier: ^1.11.9
|
||||
version: 1.11.9
|
||||
@@ -197,8 +200,8 @@ importers:
|
||||
specifier: 9.14.2
|
||||
version: 9.14.2(vue@3.5.12(typescript@5.6.2))
|
||||
vue-letter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
vue-multiselect:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
@@ -1734,8 +1737,8 @@ packages:
|
||||
'@types/node@22.7.0':
|
||||
resolution: {integrity: sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw==}
|
||||
|
||||
'@types/trusted-types@2.0.2':
|
||||
resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==}
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
@@ -2494,8 +2497,8 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.1.6:
|
||||
resolution: {integrity: sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==}
|
||||
dompurify@3.2.4:
|
||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||
|
||||
domutils@3.1.0:
|
||||
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
|
||||
@@ -3336,8 +3339,8 @@ packages:
|
||||
launch-editor@2.9.1:
|
||||
resolution: {integrity: sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==}
|
||||
|
||||
lettersanitizer@1.0.5:
|
||||
resolution: {integrity: sha512-OEJjdlRE96uKB0VfMi4nb3hBctYl1Ss6DQg9SPXRpGcjaBjcmDaibp0ZIdQu1TPwMAEvWC8xRqIcTAooElSDIg==}
|
||||
lettersanitizer@1.0.6:
|
||||
resolution: {integrity: sha512-2vj0tUtBRjlmTCFsgVlFmfi04p049Zwv/4eBGWBUOKropEoL+q+jTQQfFyDu50j7gL76pycMvrqD0uV0vxX2zg==}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
@@ -4169,8 +4172,8 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
readdirp@4.1.1:
|
||||
resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==}
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
recordrtc@5.6.2:
|
||||
@@ -4848,8 +4851,8 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-letter@0.2.0:
|
||||
resolution: {integrity: sha512-p4qHpw89GKidKyGcg4J4IjUcMQuVaTJq83c6UE4616claaLF9rj2Bg/Hq19uDCmokd+SvzEwxq6/ctfaaednkw==}
|
||||
vue-letter@0.2.1:
|
||||
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
|
||||
|
||||
vue-multiselect@3.1.0:
|
||||
resolution: {integrity: sha512-+i/fjTqFBpaay9NP+lU7obBeNaw2DdFDFs4mqhsM0aEtKRdvIf7CfREAx2o2B4XDmPrBt1r7x1YCM3BOMLaUgQ==}
|
||||
@@ -6591,7 +6594,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/trusted-types@2.0.2': {}
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
@@ -7222,7 +7225,7 @@ snapshots:
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.1
|
||||
readdirp: 4.1.2
|
||||
optional: true
|
||||
|
||||
cli-boxes@3.0.0: {}
|
||||
@@ -7477,7 +7480,9 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.1.6: {}
|
||||
dompurify@3.2.4:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.1.0:
|
||||
dependencies:
|
||||
@@ -8558,7 +8563,7 @@ snapshots:
|
||||
picocolors: 1.1.0
|
||||
shell-quote: 1.8.1
|
||||
|
||||
lettersanitizer@1.0.5: {}
|
||||
lettersanitizer@1.0.6: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
@@ -8620,7 +8625,7 @@ snapshots:
|
||||
|
||||
lit-html@2.8.0:
|
||||
dependencies:
|
||||
'@types/trusted-types': 2.0.2
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
lit@2.2.6:
|
||||
dependencies:
|
||||
@@ -9453,7 +9458,7 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
readdirp@4.1.1:
|
||||
readdirp@4.1.2:
|
||||
optional: true
|
||||
|
||||
recordrtc@5.6.2: {}
|
||||
@@ -10201,7 +10206,7 @@ snapshots:
|
||||
|
||||
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.1.6
|
||||
dompurify: 3.2.4
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.0):
|
||||
@@ -10224,9 +10229,9 @@ snapshots:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-letter@0.2.0:
|
||||
vue-letter@0.2.1:
|
||||
dependencies:
|
||||
lettersanitizer: 1.0.5
|
||||
lettersanitizer: 1.0.6
|
||||
|
||||
vue-multiselect@3.1.0: {}
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 59.1 (86144) - https://sketch.com -->
|
||||
<title>woot-log</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="woot-log" fill-rule="nonzero">
|
||||
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
|
||||
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
|
||||
<path
|
||||
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
|
||||
fill="#2781F6"
|
||||
/>
|
||||
<path
|
||||
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
|
||||
fill="white"
|
||||
stroke="white"
|
||||
stroke-width="0.1875"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="woot-logo-clip-2342424e23u32098">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 709 B |
@@ -67,6 +67,38 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
expect(contact_inboxes).to eq([])
|
||||
end
|
||||
|
||||
it 'returns limited information on inboxes' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=true",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
|
||||
contact_emails = response_body['payload'].pluck('email')
|
||||
contact_inboxes = response_body['payload'].pluck('contact_inboxes').flatten.compact
|
||||
expect(contact_emails).to include(contact.email)
|
||||
first_inbox = contact_inboxes[0]['inbox']
|
||||
expect(first_inbox).to be_a(Hash)
|
||||
expect(first_inbox).to include('id', 'channel_id', 'channel_type', 'name', 'avatar_url', 'provider')
|
||||
|
||||
expect(first_inbox).not_to include('imap_login',
|
||||
'imap_password',
|
||||
'imap_address',
|
||||
'imap_port',
|
||||
'imap_enabled',
|
||||
'imap_enable_ssl')
|
||||
|
||||
expect(first_inbox).not_to include('smtp_login',
|
||||
'smtp_password',
|
||||
'smtp_address',
|
||||
'smtp_port',
|
||||
'smtp_enabled',
|
||||
'smtp_domain')
|
||||
|
||||
expect(first_inbox).not_to include('hmac_token', 'provider_config')
|
||||
end
|
||||
|
||||
it 'returns all contacts with company name desc order' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company",
|
||||
headers: admin.create_new_auth_token,
|
||||
|
||||
@@ -255,28 +255,30 @@ RSpec.describe 'Inbox Member API', type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'renders error on invalid params' do
|
||||
it 'ignores invalid params' do
|
||||
params = { inbox_id: inbox.id, user_ids: ['invalid'] }
|
||||
original_count = inbox.inbox_members&.count
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/inbox_members",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).to include('Resource could not be found')
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(inbox.inbox_members&.count).to eq(original_count)
|
||||
end
|
||||
|
||||
it 'renders error on non member params' do
|
||||
it 'ignores non member params' do
|
||||
params = { inbox_id: inbox.id, user_ids: [non_member_agent.id] }
|
||||
original_count = inbox.inbox_members&.count
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/inbox_members",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).to include('Resource could not be found')
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(inbox.inbox_members&.count).to eq(original_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,10 +39,10 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is a success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { meeting: { id: 'meeting_id', roomName: 'room_name' } } }.to_json,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
@@ -62,7 +62,7 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and the Dyte API is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
@@ -112,24 +112,24 @@ RSpec.describe 'Dyte Integration API', type: :request do
|
||||
|
||||
context 'when it is an agent with inbox access and message_type is integrations' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns authResponse' do
|
||||
it 'returns auth_token' do
|
||||
post add_participant_to_meeting_api_v1_account_integrations_dyte_url(account),
|
||||
params: { message_id: integration_message.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['authResponse']).to eq(
|
||||
expect(response_body).to eq(
|
||||
{
|
||||
'userAdded' => true, 'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -46,15 +46,15 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
|
||||
|
||||
context 'when message is an integration message' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns authResponse' do
|
||||
it 'returns auth_token' do
|
||||
post add_participant_to_meeting_api_v1_widget_integrations_dyte_url,
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: { website_token: web_widget.website_token, message_id: integration_message.id },
|
||||
@@ -62,9 +62,9 @@ RSpec.describe '/api/v1/widget/integrations/dyte', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['authResponse']).to eq(
|
||||
expect(response_body).to eq(
|
||||
{
|
||||
'userAdded' => true, 'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
'id' => 'random_uuid', 'auth_token' => 'json-web-token'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -11,23 +11,23 @@ describe Dyte do
|
||||
context 'when create_a_meeting is called' do
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { meeting: { id: 'meeting_id' } } }.to_json,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns api response' do
|
||||
response = dyte_client.create_a_meeting('title_of_the_meeting')
|
||||
expect(response).to eq({ 'meeting' => { 'id' => 'meeting_id' } })
|
||||
expect(response).to eq({ 'id' => 'meeting_id' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
@@ -47,23 +47,23 @@ describe Dyte do
|
||||
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns api response' do
|
||||
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
|
||||
expect(response).to eq({ 'authResponse' => { 'userAdded' => true, 'id' => 'random_uuid', 'auth_token' => 'json-web-token' } })
|
||||
expect(response).to eq({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ describe Integrations::Dyte::ProcessorService do
|
||||
describe '#create_a_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { meeting: { id: 'meeting_id', roomName: 'room_name' } } }.to_json,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
@@ -32,7 +32,7 @@ describe Integrations::Dyte::ProcessorService do
|
||||
|
||||
context 'when the API response is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meeting')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
@@ -51,17 +51,17 @@ describe Integrations::Dyte::ProcessorService do
|
||||
describe '#add_participant_to_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.cluster.dyte.in/v1/organizations/org_id/meetings/m_id/participant')
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { authResponse: { userAdded: true, id: 'random_uuid', auth_token: 'json-web-token' } } }.to_json,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'return the authResponse' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent)
|
||||
expect(response[:authResponse]).not_to be_nil
|
||||
expect(response).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -162,15 +162,13 @@ describe Integrations::Slack::SendOnSlackService do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload).with(hash_including(
|
||||
channels: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!',
|
||||
filetype: 'png',
|
||||
content: anything,
|
||||
filename: attachment.file.filename,
|
||||
title: attachment.file.filename
|
||||
)).and_return(file_attachment)
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
filename: attachment.file.filename,
|
||||
content: anything,
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
|
||||
|
||||
@@ -114,4 +114,26 @@ RSpec.describe Campaign do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when validating sender' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:web_widget) { create(:channel_widget, account: account) }
|
||||
let(:inbox) { create(:inbox, channel: web_widget, account: account) }
|
||||
|
||||
it 'allows sender from the same account' do
|
||||
campaign = build(:campaign, inbox: inbox, account: account, sender: user)
|
||||
expect(campaign).to be_valid
|
||||
end
|
||||
|
||||
it 'does not allow sender from different account' do
|
||||
other_account = create(:account)
|
||||
other_user = create(:user, account: other_account)
|
||||
campaign = build(:campaign, inbox: inbox, account: account, sender: other_user)
|
||||
expect(campaign).not_to be_valid
|
||||
expect(campaign.errors[:sender_id]).to include(
|
||||
'must belong to the same account as the campaign'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+33
-12
@@ -41,29 +41,50 @@ RSpec.describe Inbox do
|
||||
it_behaves_like 'avatarable'
|
||||
end
|
||||
|
||||
describe '#add_member' do
|
||||
describe '#add_members' do
|
||||
let(:inbox) { FactoryBot.create(:inbox) }
|
||||
let(:user) { FactoryBot.create(:user) }
|
||||
|
||||
it do
|
||||
expect(inbox.inbox_members.size).to eq(0)
|
||||
before do
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
inbox.add_member(user.id)
|
||||
expect(inbox.reload.inbox_members.size).to eq(1)
|
||||
it 'handles adds all members and resets cache keys' do
|
||||
users = FactoryBot.create_list(:user, 3)
|
||||
inbox.add_members(users.map(&:id))
|
||||
expect(inbox.reload.inbox_members.size).to eq(3)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
||||
.with(
|
||||
'account.cache_invalidated',
|
||||
kind_of(Time),
|
||||
account: inbox.account,
|
||||
cache_keys: inbox.account.cache_keys
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#remove_member' do
|
||||
describe '#remove_members' do
|
||||
let(:inbox) { FactoryBot.create(:inbox) }
|
||||
let(:user) { FactoryBot.create(:user) }
|
||||
let(:users) { FactoryBot.create_list(:user, 3) }
|
||||
|
||||
before { inbox.add_member(user.id) }
|
||||
before do
|
||||
inbox.add_members(users.map(&:id))
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
it do
|
||||
expect(inbox.inbox_members.size).to eq(1)
|
||||
it 'removes the members and resets cache keys' do
|
||||
expect(inbox.reload.inbox_members.size).to eq(3)
|
||||
|
||||
inbox.remove_member(user.id)
|
||||
inbox.remove_members(users.map(&:id))
|
||||
expect(inbox.reload.inbox_members.size).to eq(0)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
||||
.with(
|
||||
'account.cache_invalidated',
|
||||
kind_of(Time),
|
||||
account: inbox.account,
|
||||
cache_keys: inbox.account.cache_keys
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,4 +6,51 @@ RSpec.describe Team do
|
||||
it { is_expected.to have_many(:conversations) }
|
||||
it { is_expected.to have_many(:team_members) }
|
||||
end
|
||||
|
||||
describe '#add_members' do
|
||||
let(:team) { FactoryBot.create(:team) }
|
||||
|
||||
before do
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
it 'handles adds all members and resets cache keys' do
|
||||
users = FactoryBot.create_list(:user, 3)
|
||||
team.add_members(users.map(&:id))
|
||||
expect(team.reload.team_members.size).to eq(3)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
||||
.with(
|
||||
'account.cache_invalidated',
|
||||
kind_of(Time),
|
||||
account: team.account,
|
||||
cache_keys: team.account.cache_keys
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#remove_members' do
|
||||
let(:team) { FactoryBot.create(:team) }
|
||||
let(:users) { FactoryBot.create_list(:user, 3) }
|
||||
|
||||
before do
|
||||
team.add_members(users.map(&:id))
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
|
||||
it 'removes the members and resets cache keys' do
|
||||
expect(team.reload.team_members.size).to eq(3)
|
||||
|
||||
team.remove_members(users.map(&:id))
|
||||
expect(team.reload.team_members.size).to eq(0)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
||||
.with(
|
||||
'account.cache_invalidated',
|
||||
kind_of(Time),
|
||||
account: team.account,
|
||||
cache_keys: team.account.cache_keys
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,10 +55,6 @@ const tailwindConfig = {
|
||||
},
|
||||
overflowWrap: 'anywhere',
|
||||
|
||||
'br + br': {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
strong: {
|
||||
color: 'rgb(var(--slate-12))',
|
||||
fontWeight: '700',
|
||||
|
||||
Reference in New Issue
Block a user