Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring

This commit is contained in:
Muhsin Keloth
2026-04-28 11:00:45 +04:00
committed by GitHub
162 changed files with 4889 additions and 863 deletions
+1
View File
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
+4
View File
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
+5 -1
View File
@@ -44,7 +44,11 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: account_name, locale: I18n.locale)
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account
end
+2 -1
View File
@@ -29,8 +29,9 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
@@ -0,0 +1,43 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
end
private
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
email = account_params[:email].presence || @user&.email
return if email.blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :password)
params.permit(:otp_code, :backup_code, :password)
end
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
end
def show; end
@@ -0,0 +1,68 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
+3 -3
View File
@@ -59,7 +59,6 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -107,8 +106,9 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -131,7 +131,7 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
v-if="!authUIFlags.isFetching"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -233,6 +233,7 @@ onMounted(() => resetContacts());
<Popover
ref="popoverRef"
:align="align"
:show-content-border="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -18,6 +18,7 @@ const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
// eslint-disable-next-line vue/no-unused-properties
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -199,7 +200,6 @@ useEventListener(document, 'paste', onPaste);
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<ContentTemplateParser
:template="template"
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
);
});
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
)
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<ContentTemplateForm
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
}}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<WhatsappTemplate
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
COMPANY_NAME: 'company_name',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
@@ -30,6 +30,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
@@ -82,6 +86,7 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -106,6 +111,7 @@ defineExpose({
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@@ -12,6 +12,14 @@ const props = defineProps({
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
disableMobileView: {
type: Boolean,
default: false,
},
showContentBorder: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['show', 'hide']);
@@ -22,7 +30,8 @@ const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const belowMd = breakpoints.smaller('md');
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
@@ -99,9 +108,13 @@ defineExpose({ show, hide, toggle });
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
</div>
@@ -113,9 +126,14 @@ defineExpose({ show, hide, toggle });
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
:class="{ 'border border-n-strong': showContentBorder }"
>
<slot name="content" :hide="hide" />
</div>
</div>
</TeleportWithDirection>
</template>
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
});
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
};
}
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
@@ -182,6 +182,7 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Company",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -387,6 +387,7 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Country",
"CITY": "City",
"COMPANY": "Company",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Referer link",
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -39,6 +39,7 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import yearInReview from './yearInReview.json';
export default {
@@ -83,5 +84,6 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...onboarding,
...yearInReview,
};
@@ -51,10 +51,14 @@
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "Select timezone",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "Saving...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -60,9 +60,7 @@ const onDelete = async hide => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-80 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
@@ -72,9 +72,7 @@ const onMergeContacts = async (parentContactId, hide) => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-96 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('MERGE_CONTACTS.TITLE') }}
@@ -53,6 +53,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'company_name',
attributeI18nKey: 'COMPANY',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
@@ -124,6 +132,10 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
{
key: 'company_name',
i18nKey: 'COMPANY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
export default {
routes: [
@@ -31,6 +32,14 @@ export default {
...campaignsRoutes.routes,
],
},
{
path: frontendURL('accounts/:accountId/onboarding'),
name: 'onboarding_account_details',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
</div>
</template>
@@ -0,0 +1,417 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
COMPANY_SIZE_OPTIONS,
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
const userRole = ref('');
const website = ref('');
const locale = ref('');
const timezone = ref('');
const companySize = ref('');
const industry = ref('');
const referralSource = ref('');
const isSubmitting = ref(false);
const isEditingWebsite = ref(false);
const websiteInput = ref(null);
const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
locale: {},
timezone: {},
companySize: {},
industry: {},
referralSource: {},
};
const v$ = useVuelidate(validationRules, {
userRole,
website,
locale,
timezone,
companySize,
industry,
referralSource,
});
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
return (square || logos[0])?.url || '';
});
const languageOptions = computed(() => {
const langs = [...(enabledLanguages || [])];
return langs
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
});
const timezoneOptions = computed(() => {
try {
return Intl.supportedValuesOf('timeZone').map(tz => ({
value: tz,
label: tz.replace(/_/g, ' '),
}));
} catch {
return [];
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = account?.domain || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
};
const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values the controller would otherwise clear
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
showErrorOnFields.value = true;
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
return;
}
isSubmitting.value = true;
try {
await updateAccount({
name: accountName.value,
locale: locale.value,
domain: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
referral_source: referralSource.value,
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<OnboardingLayout
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
:is-loading="isSubmitting"
:disabled="isEnriching"
>
<OnboardingSection
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
icon="i-lucide-user"
>
<div class="flex items-center gap-2 px-3 py-3">
<Avatar :name="userName" :size="16" rounded-full />
<span class="text-sm font-medium text-n-slate-12">
{{ userName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
icon="i-lucide-mail"
>
<div class="flex items-center justify-end gap-1.5">
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
<Icon
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
icon="i-lucide-circle-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
icon="i-lucide-user"
>
<OnboardingFormSelect
v-model="userRole"
:has-error="showErrorOnFields && v$.userRole.$error"
:options="USER_ROLE_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
/>
</OnboardingFormRow>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
icon="i-lucide-briefcase-business"
>
<div
v-if="isEnriching"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
</span>
</div>
<template v-else>
<div class="flex items-center gap-2 px-3 py-3">
<img
v-if="companyLogo"
:src="companyLogo"
:alt="accountName"
class="size-4 object-contain"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ accountName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
icon="i-lucide-globe"
>
<div class="flex items-center justify-end gap-2">
<InlineInput
ref="websiteInput"
v-model="website"
:readonly="!isEditingWebsite"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
:custom-input-class="[
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
{ 'animate-shake': showErrorOnFields && v$.website.$error },
]"
@enter-press="handleWebsiteEnter"
@blur="isEditingWebsite = false"
/>
<NextButton
type="button"
icon="i-lucide-pencil"
slate
xs
ghost
@click="enableWebsiteEditing"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
icon="i-lucide-languages"
>
<OnboardingFormSelect
v-model="locale"
:has-error="showErrorOnFields && v$.locale.$error"
:options="languageOptions"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
icon="i-lucide-clock"
>
<OnboardingFormSelect
v-model="timezone"
:has-error="showErrorOnFields && v$.timezone.$error"
:options="timezoneOptions"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
icon="i-lucide-factory"
>
<OnboardingFormSelect
v-model="industry"
:has-error="showErrorOnFields && v$.industry.$error"
:options="INDUSTRY_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
icon="i-lucide-users"
>
<OnboardingFormSelect
v-model="companySize"
:has-error="showErrorOnFields && v$.companySize.$error"
:options="COMPANY_SIZE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
icon="i-lucide-megaphone"
>
<OnboardingFormSelect
v-model="referralSource"
:has-error="showErrorOnFields && v$.referralSource.$error"
:options="REFERRAL_SOURCE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
"
/>
</OnboardingFormRow>
</template>
</OnboardingSection>
</OnboardingLayout>
</form>
</template>
@@ -0,0 +1,18 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
<div class="flex items-center gap-2">
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
<span class="text-n-slate-11">{{ title }}</span>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
hasError: { type: Boolean, default: false },
});
defineEmits(['update:modelValue']);
</script>
<template>
<div class="relative flex items-center justify-end">
<select
:value="modelValue"
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
:class="[
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
{ 'animate-shake': hasError },
]"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<Icon
icon="i-lucide-chevron-down"
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
/>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
</script>
<template>
<div
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
>
<!-- Grid background with corner fade -->
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
width="1"
height="100%"
preserveAspectRatio="none"
>
<line
x1="0"
y1="0"
x2="0"
y2="97%"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="3 3"
/>
</svg>
<!-- Greeting -->
<div class="mb-6 -ms-12 flex items-start gap-4">
<div
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
>
<slot name="greeting-icon">
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
</slot>
</div>
<div>
<h1 class="text-heading-1 text-n-slate-12">
{{ greeting }}
</h1>
<p v-if="subtitle" class="text-sm text-n-slate-11">
{{ subtitle }}
</p>
</div>
</div>
<!-- Sections -->
<slot />
</div>
<!-- Continue button with curved connector -->
<div class="relative ps-12 overflow-visible">
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
<svg
width="48"
height="40"
viewBox="0 0 47 40"
fill="none"
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
>
<defs>
<linearGradient
id="line-gradient"
x1="15"
y1="0"
x2="48"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
</linearGradient>
</defs>
<path
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
stroke="url(#line-gradient)"
stroke-width="1"
stroke-dasharray="3 3"
fill="none"
/>
</svg>
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="mb-5">
<!-- Section header with icon + triangles -->
<div class="flex items-center gap-4 mb-3 -ms-12">
<div class="flex flex-col items-center z-10 flex-shrink-0">
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
</svg>
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
>
<Icon :icon="icon" class="size-4 text-n-slate-11" />
</div>
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
</svg>
</div>
<span class="text-heading-3 text-n-slate-12">
{{ title }}
</span>
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,68 @@
export const COMPANY_SIZE_OPTIONS = [
{ value: '1-10', label: '1 - 10' },
{ value: '11-50', label: '11 - 50' },
{ value: '51-200', label: '51 - 200' },
{ value: '201-500', label: '201 - 500' },
{ value: '500+', label: '500+' },
];
export const INDUSTRY_OPTIONS = [
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
{
value: 'Automotive & Transportation',
label: 'Automotive & Transportation',
},
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
{
value: 'Construction & Built Environment',
label: 'Construction & Built Environment',
},
{
value: 'Consumer Packaged Goods (CPG)',
label: 'Consumer Packaged Goods (CPG)',
},
{ value: 'Education', label: 'Education' },
{ value: 'Entertainment', label: 'Entertainment' },
{ value: 'Finance', label: 'Finance' },
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
{ value: 'Healthcare', label: 'Healthcare' },
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
{ value: 'News & Media', label: 'News & Media' },
{
value: 'Professional Services & Agencies',
label: 'Professional Services & Agencies',
},
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
{ value: 'Sports', label: 'Sports' },
{ value: 'Technology', label: 'Technology' },
{ value: 'Telecommunications', label: 'Telecommunications' },
{ value: 'Other', label: 'Other' },
];
export const REFERRAL_SOURCE_OPTIONS = [
{ value: 'google', label: 'Google' },
{ value: 'reddit', label: 'Reddit' },
{ value: 'twitter', label: 'Twitter/X' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'friend', label: 'Friend/Colleague' },
{ value: 'blog', label: 'Blog/Article' },
{ value: 'github', label: 'GitHub' },
{ value: 'other', label: 'Other' },
];
export const USER_ROLE_OPTIONS = [
{ value: 'founder', label: 'Founder/CEO' },
{ value: 'product_manager', label: 'Product Manager' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'support_lead', label: 'Support Lead' },
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'other', label: 'Other' },
];
@@ -74,6 +74,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'labels',
name: 'LABELS',
@@ -180,6 +186,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'referer',
name: 'REFERER_LINK',
@@ -314,6 +326,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
@@ -460,6 +478,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -590,6 +614,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -157,12 +168,32 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -104,9 +104,9 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
const disableMfa = async ({ password, otpCode, backupCode }) => {
try {
await mfaAPI.disable(password, otpCode);
await mfaAPI.disable(password, { otpCode, backupCode });
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
+24 -6
View File
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
import dashboard from './dashboard/dashboard.routes';
import store from 'dashboard/store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const routes = [...dashboard.routes];
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = (to, next) => {
export const validateAuthenticateRoutePermission = async (to, next) => {
const { isLoggedIn, getCurrentUser: user } = store.getters;
if (!isLoggedIn) {
@@ -27,8 +29,25 @@ export const validateAuthenticateRoutePermission = (to, next) => {
return next(frontendURL('no-accounts'));
}
const routeAccountId = Number(to.params?.accountId || accountId);
const userAccount = accounts.find(a => a.id === routeAccountId);
const isAdmin = userAccount?.role === 'administrator';
const isActive = userAccount?.status === 'active';
const needsOnboarding =
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
isAdmin &&
isActive;
if (to.name === 'no_accounts' || !to.name) {
return next(frontendURL(`accounts/${accountId}/dashboard`));
const target = needsOnboarding ? 'onboarding' : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
@@ -38,15 +57,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
userAuthentication.then(() => {
return validateAuthenticateRoutePermission(to, next, store);
});
await userAuthentication;
await validateAuthenticateRoutePermission(to, next, store);
});
};
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
id: null,
accounts: [],
},
'accounts/getAccount': () => ({}),
},
dispatch: vi.fn(() => Promise.resolve()),
},
}));
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
});
describe('when route is not accessible to current user', () => {
it('should redirect to dashboard', () => {
it('should redirect to dashboard', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
});
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
};
});
it('should go to the intended route', () => {
it('should go to the intended route', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith();
});
@@ -54,18 +54,19 @@ export const getters = {
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
get: async ({ commit }, { silent } = {}) => {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
}
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch {
// silent failure
} finally {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
}
}
},
update: async ({ commit }, { options, ...updateObj }) => {
@@ -269,6 +269,20 @@ export const mutations = {
accounts,
};
},
[types.RESET_ONBOARDING](_state, accountId) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === accountId) {
const { onboarding_step, ...rest } = account;
return rest;
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
bulkTranslate: async (
_,
{ portalSlug, articleIds, locale, categoryId, force = false }
) => {
const { data } = await articlesAPI.bulkTranslate({
portalSlug,
articleIds,
locale,
categoryId,
force,
});
return data;
},
};
@@ -57,4 +57,46 @@ describe('#mutations', () => {
);
});
});
describe('#RESET_ONBOARDING', () => {
it('removes onboarding_step from the targeted account', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{
id: 1,
onboarding_step: 'account_details',
role: 'administrator',
},
],
},
};
mutations[types.RESET_ONBOARDING](state, 1);
expect(state.currentUser.accounts[0]).not.toHaveProperty(
'onboarding_step'
);
expect(state.currentUser.accounts[0].role).toEqual('administrator');
});
it('targets the route account, not currentUser.account_id', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{ id: 1, onboarding_step: 'account_details' },
{ id: 2, onboarding_step: 'account_details' },
],
},
};
mutations[types.RESET_ONBOARDING](state, 2);
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
'account_details'
);
expect(state.currentUser.accounts[1]).not.toHaveProperty(
'onboarding_step'
);
});
});
});
@@ -4,6 +4,7 @@ export default {
SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
RESET_ONBOARDING: 'RESET_ONBOARDING',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
+84 -84
View File
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
"DOWNLOAD": "Laadi alla",
"UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
"SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
"RETRY": "Send message again",
"ERROR_MESSAGE": "Couldn't send, try again"
"RETRY": "Saada sõnum uuesti",
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"ONLINE": "Oleme võrgus",
"OFFLINE": "Oleme hetkel eemal",
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
"BACK_AT_TIME": "Oleme tagasi kell {time}",
"BACK_ON_DAY": "Oleme tagasi {day}",
"BACK_TOMORROW": "Oleme tagasi homme",
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
"MONDAY": "Monday",
"TUESDAY": "Tuesday",
"WEDNESDAY": "Wednesday",
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
"SUNDAY": "Pühapäev",
"MONDAY": "Esmaspäev",
"TUESDAY": "Teisipäev",
"WEDNESDAY": "Kolmapäev",
"THURSDAY": "Neljapäev",
"FRIDAY": "Reede",
"SATURDAY": "Laupäev"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"START_NEW_CONVERSATION": "Start a new conversation",
"VIEW_UNREAD_MESSAGES": "You have unread messages",
"START_CONVERSATION": "Alusta vestlust",
"END_CONVERSATION": "Lõpeta vestlus",
"CONTINUE_CONVERSATION": "Jätka vestlust",
"YOU": "Sina",
"START_NEW_CONVERSATION": "Alusta uut vestlust",
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close",
"COMPANY_FROM": "from",
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
"CLOSE_MESSAGES_BUTTON": "Sulge",
"COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
"LABEL": "Chat with us"
"LABEL": "Vestle meiega"
},
"POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"POWERED_BY": "Toetab Chatwoot",
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
"TODAY": "Täna",
"YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"REQUIRED_ERROR": "Full Name is required"
"LABEL": "Täisnimi",
"PLACEHOLDER": "Palun sisesta oma täisnimi",
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"REQUIRED_ERROR": "Email Address is required",
"VALID_ERROR": "Please enter a valid email address"
"LABEL": "E-posti aadress",
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter your phone number",
"REQUIRED_ERROR": "Phone Number is required",
"DIAL_CODE_VALID_ERROR": "Please select a country code",
"VALID_ERROR": "Please enter a valid phone number",
"DROPDOWN_EMPTY": "No results found",
"DROPDOWN_SEARCH": "Search country"
"LABEL": "Telefoninumber",
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
"DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
"LABEL": "Sõnum",
"PLACEHOLDER": "Palun sisesta oma sõnum",
"ERROR": "Sõnum on liiga lühike"
}
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
"IS_REQUIRED": "on kohustuslik",
"REQUIRED": "Kohustuslik",
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
"FIELD": "Invalid field"
"FIELD": "Vigane väli"
}
},
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
"PLACEHOLDER": "Otsi emotikone",
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
"PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
"BUTTON_TEXT": "Request a conversation transcript",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again"
"BUTTON_TEXT": "Taotle vestluse koopiat",
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
"CLICK_HERE_TO_JOIN": "Click here to join",
"LEAVE_THE_ROOM": "Leave the call"
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
"LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"POPULAR_ARTICLES": "Populaarsed artiklid",
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
"CONTENT": "Picture message"
"CONTENT": "Pildisõnum"
},
"audio": {
"CONTENT": "Audio message"
"CONTENT": "Helisõnum"
},
"video": {
"CONTENT": "Video message"
"CONTENT": "Videosõnum"
},
"file": {
"CONTENT": "File Attachment"
"CONTENT": "Faili manus"
},
"location": {
"CONTENT": "Location"
"CONTENT": "Asukoht"
},
"fallback": {
"CONTENT": "has shared a url"
"CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
"REPLY_TO": "Replying to:"
"REPLY_TO": "Vastus sõnumile:"
}
}
+6 -1
View File
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
def attach_export_file(csv_data)
return if csv_data.blank?
# Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
# correctly recognise the file encoding for non-ASCII characters
# such as Arabic, Japanese, and Chinese.
bom = "\xEF\xBB\xBF"
@account.contacts_export.attach(
io: StringIO.new(csv_data),
io: StringIO.new("#{bom}#{csv_data}"),
filename: "#{@account.name}_#{@account.id}_contacts.csv",
content_type: 'text/csv'
)
+2 -2
View File
@@ -1,6 +1,6 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
rescue Webhooks::Trigger::RetryableError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end
+42 -17
View File
@@ -9,27 +9,17 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
include UrlHelper
queue_as :purgable
MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Avatarable::ALLOWED_AVATAR_CONTENT_TYPES
MAX_DOWNLOAD_SIZE = 15.megabytes
RATE_LIMIT_WINDOW = 1.minute
def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar)
return unless url_valid?(avatar_url)
return unless syncable_avatar?(avatarable, avatar_url)
return unless should_sync_avatar?(avatarable, avatar_url)
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
fetch_and_attach_avatar(avatarable, avatar_url)
rescue SafeFetch::HttpError => e
log_http_error(avatar_url, e)
rescue SafeFetch::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure
update_avatar_sync_attributes(avatarable, avatar_url)
@@ -37,6 +27,41 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
private
def syncable_avatar?(avatarable, avatar_url)
avatarable.respond_to?(:avatar) &&
url_valid?(avatar_url) &&
should_sync_avatar?(avatarable, avatar_url)
end
def fetch_and_attach_avatar(avatarable, avatar_url)
SafeFetch.fetch(
avatar_url,
max_bytes: MAX_DOWNLOAD_SIZE,
allowed_content_type_prefixes: [],
allowed_content_types: ALLOWED_CONTENT_TYPES
) do |avatar_file|
attach_avatar(avatarable, avatar_file)
end
end
def attach_avatar(avatarable, avatar_file)
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file.tempfile,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
end
def log_http_error(avatar_url, error)
if error.message.start_with?('404')
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
else
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
end
end
def should_sync_avatar?(avatarable, avatar_url)
# Only Contacts are rate-limited and hash-gated.
return true unless avatarable.is_a?(Contact)
+1
View File
@@ -106,6 +106,7 @@ class DataImportJob < ApplicationJob
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
clean_data = clean_data.delete_prefix("\xEF\xBB\xBF")
CSV.new(StringIO.new(clean_data), headers: true)
end
+8
View File
@@ -157,6 +157,14 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
def onboarding_step
step = custom_attributes['onboarding_step']
return nil if step.blank?
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
private
def notify_creation
+1
View File
@@ -32,6 +32,7 @@ class AgentBot < ApplicationRecord
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
+1 -1
View File
@@ -35,7 +35,7 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) }
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
mail_subject phone_number priority conversation_language labels private_note]
end
+3 -2
View File
@@ -4,6 +4,8 @@ module Avatarable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
ALLOWED_AVATAR_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
included do
has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? }
@@ -30,7 +32,6 @@ module Avatarable
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
errors.add(:avatar, 'filetype not supported') unless ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
end
end
+1 -1
View File
@@ -25,7 +25,7 @@ class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
+1
View File
@@ -75,6 +75,7 @@ class User < ApplicationRecord
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :name, presence: true
validates :email, presence: true
serialize :otp_backup_codes, type: Array
@@ -39,7 +39,8 @@ class Messages::SearchDataPresenter < SimpleDelegator
end
def content_attributes_data
email_subject = content_attributes.dig(:email, :subject)
email_subject = content_attributes.dig(:email, :subject).presence ||
conversation.additional_attributes&.dig('mail_subject').presence
return {} if email_subject.blank?
{ email: { subject: email_subject } }
+1 -1
View File
@@ -61,7 +61,7 @@ class DataImport::ContactManager
def update_contact_attributes(params, contact)
contact.name = params[:name] if params[:name].present?
contact.additional_attributes ||= {}
contact.additional_attributes[:company] = params[:company] if params[:company].present?
contact.additional_attributes[:company_name] = params[:company_name] if params[:company_name].present?
contact.additional_attributes[:city] = params[:city] if params[:city].present?
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
end
@@ -0,0 +1,160 @@
class Notification::PushTestService
pattr_initialize [:user!, :subscription_ids!, :title, :body]
DEFAULT_TITLE = '%<installation_name>s notification test'.freeze
DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
def self.default_title
format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
end
def self.default_body
DEFAULT_BODY
end
def perform
selected_subscriptions.map { |subscription| test_send(subscription) }
end
private
def resolved_title
title.presence || self.class.default_title
end
def resolved_body
body.presence || self.class.default_body
end
def selected_subscriptions
user.notification_subscriptions.where(id: subscription_ids).order(:id)
end
def test_send(subscription)
if subscription.browser_push?
test_browser_push(subscription)
elsif subscription.fcm?
test_fcm(subscription)
else
result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
end
end
def test_browser_push(subscription)
return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
WebPush.payload_send(**browser_push_payload(subscription))
result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
rescue StandardError => e
result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm(subscription)
if firebase_credentials_present?
test_fcm_direct(subscription)
elsif chatwoot_hub_enabled?
test_fcm_via_hub(subscription)
else
result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
end
end
def test_fcm_direct(subscription)
fcm_service = Notification::FcmService.new(
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
)
response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
status_code = response[:status_code].to_i
status = status_code.between?(200, 299) ? :success : :failure
result(subscription, 'fcm', status, "HTTP #{status_code}#{response[:body]}")
rescue StandardError => e
result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm_via_hub(subscription)
response = ChatwootHub.send_push_with_response(fcm_options(subscription))
result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code}#{response.body}")
rescue RestClient::ExceptionWithResponse => e
result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code}#{e.response&.body}")
rescue StandardError => e
result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
end
def firebase_credentials_present?
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
end
def chatwoot_hub_enabled?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
end
def browser_push_payload(subscription)
{
message: JSON.generate(
title: resolved_title,
tag: "super_admin_test_#{Time.zone.now.to_i}",
url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
auth: subscription.subscription_attributes['auth'],
vapid: {
subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
public_key: VapidService.public_key,
private_key: VapidService.private_key
},
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
}
end
def fcm_options(subscription)
{
'token': subscription.subscription_attributes['push_token'],
'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
'notification': { title: resolved_title, body: resolved_body },
'android': { priority: 'high' },
'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
'fcm_options': { analytics_label: 'SuperAdminTest' }
}
end
def result(subscription, type, status, message)
attrs = subscription.subscription_attributes || {}
{
id: subscription.id,
type: type.to_s,
device: device_label(subscription, attrs),
token_tail: token_tail(subscription, attrs),
status: status,
message: message
}
end
def device_label(subscription, attrs)
if subscription.browser_push?
endpoint_host(attrs['endpoint'].to_s)
else
attrs['device_id'].present? ? "#{attrs['device_id'].to_s.last(6)}" : '—'
end
end
def endpoint_host(endpoint)
return '—' if endpoint.blank?
URI.parse(endpoint).host.presence || endpoint
rescue URI::InvalidURIError
endpoint
end
def token_tail(subscription, attrs)
if subscription.browser_push?
endpoint = attrs['endpoint'].to_s
endpoint.present? ? "#{endpoint.last(6)}" : '—'
else
attrs['push_token'].present? ? "#{attrs['push_token'].to_s.last(6)}" : '—'
end
end
end
@@ -10,7 +10,9 @@ if resource.custom_attributes.present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
@@ -22,6 +22,7 @@ json.accounts do
json.id account_user.account_id
json.name account_user.account.name
json.status account_user.account.status
json.onboarding_step account_user.account.onboarding_step
json.active_at account_user.active_at
json.role account_user.role
json.permissions account_user.permissions
@@ -0,0 +1,89 @@
<tr>
<td style="padding: 0 0 16px;">
<span style="display: inline-block; padding: 6px 10px; border-radius: 999px; background-color: #EFF6FF; color: #1D4ED8; font-size: 12px; line-height: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;">
<%= eyebrow %>
</span>
</td>
</tr>
<tr>
<td style="padding: 0 0 12px;">
<h1 style="margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; color: #0F172A;"><%= heading %></h1>
</td>
</tr>
<tr>
<td style="padding: 0 0 8px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #334155;">Hi <%= recipient_name %>,</p>
</td>
</tr>
<tr>
<td style="padding: 0 0 10px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= intro_text %></p>
</td>
</tr>
<tr>
<td style="padding: 0 0 <%= detail_rows.any? || info_title.present? ? '16px' : '24px' %>;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= supporting_text %></p>
</td>
</tr>
<% if detail_rows.any? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 16px; background-color: #F8FBFF;">
<% detail_rows.each_with_index do |(label, value), index| %>
<tr>
<td style="padding: 16px 18px;<%= ' border-top: 1px solid #E2E8F0;' unless index.zero? %>">
<p style="margin: 0 0 4px; font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748B;">
<%= label %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; font-weight: 600; color: #0F172A;"><%= value %></p>
</td>
</tr>
<% end %>
</table>
</td>
</tr>
<% end %>
<% if action_url.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
<tr>
<td bgcolor="#2781F6" style="border-radius: 14px; background-color: #2781F6;">
<%= link_to(
action_text,
action_url,
style: 'display:block; width:100%; box-sizing:border-box; padding:12px 24px; font-size:14px; line-height:20px; font-weight:700; color:#FFFFFF; text-align:center; text-decoration:none;'
) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 0 0 20px;">
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #64748B;">
If the button does not work, open
<%= link_to(
'this secure link',
action_url,
style: 'color:#2781F6; text-decoration:none; font-weight:600;'
) %>.
</p>
</td>
</tr>
<% elsif info_title.present? %>
<tr>
<td style="padding: 0 0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DBEAFE; border-radius: 16px; background-color: #EFF6FF;">
<tr>
<td style="padding: 18px;">
<p style="margin: 0 0 6px; font-size: 14px; line-height: 21px; font-weight: 700; color: #0F172A;">
<%= info_title %>
</p>
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= info_text %></p>
</td>
</tr>
</table>
</td>
</tr>
<% end %>
@@ -1,29 +1,65 @@
<p>Hi <%= @resource.name %>,</p>
<%
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
recipient_name = @resource.name.presence || @resource.email
account_user = @resource&.account_users&.first
inviter = account_user&.inviter
account_name = account_user&.account&.name
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
<% account_user = @resource&.account_users&.first %>
eyebrow = 'Welcome'
heading = 'Confirm your email to get started'
intro_text =
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
supporting_text = 'This only takes a moment.'
action_text = 'Confirm my account'
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
info_title = nil
info_text = nil
detail_rows = []
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
if @resource.unconfirmed_email.present?
eyebrow = 'Email update'
heading = 'Confirm your new email address'
intro_text = "We received a request to update the email address on your #{brand_name} account."
supporting_text = 'Confirm the new address below to finish the change.'
action_text = 'Confirm email address'
elsif @resource.confirmed?
eyebrow = 'Account ready'
heading = 'Your account is ready'
intro_text = "Your #{brand_name} account is already active."
supporting_text = 'Use the button below to sign in and continue where you left off.'
action_text = 'Open my account'
action_url = frontend_url('auth/sign_in')
detail_rows = []
elsif invited_user
eyebrow = 'Workspace invitation'
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
intro_text = if account_name.present?
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
else
"#{inviter.name} invited you to try #{brand_name}."
end
supporting_text = 'Create your account to start collaborating with your team.'
action_text = 'Accept invitation'
action_url = frontend_url(
'auth/password/edit',
reset_password_token: @resource.send(:set_reset_password_token)
)
detail_rows = [['Invited by', inviter.name]]
detail_rows << ['Workspace', account_name] if account_name.present?
end
%>
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
</p>
<% end %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% elsif account_user&.inviter.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>
<%= render partial: 'devise/mailer/confirmation_body', locals: {
action_text: action_text,
action_url: action_url,
detail_rows: detail_rows,
eyebrow: eyebrow,
heading: heading,
info_text: info_text,
info_title: info_title,
intro_text: intro_text,
recipient_name: recipient_name,
supporting_text: supporting_text
} %>
+98 -55
View File
@@ -7,86 +7,129 @@
<style type="text/css">
img {
max-width: 100%;
border: none;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
height: 100%;
line-height: 1.6em;
line-height: 21px;
width: 100% !important;
margin: 0;
padding: 0;
background-color: #F4F7FB;
}
body {
background-color: #F8FAFC;
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
a {
color: #2781F6;
}
.email-container {
width: 100%;
max-width: 640px;
}
.main-card {
width: 100%;
border: 1px solid #DCE7F5;
border-radius: 24px;
background-color: #FFFFFF;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.accent-bar {
height: 6px;
background-color: #2781F6;
font-size: 0;
line-height: 0;
}
.content-wrap {
padding: 40px 36px 20px;
}
.footer {
padding: 20px 8px 0;
text-align: center;
color: #64748B;
font-size: 13px;
line-height: 21px;
}
.footer a {
color: #2781F6;
font-weight: 600;
text-decoration: none;
}
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
h1 {
font-size: 22px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
font-size: 28px !important;
line-height: 34px !important;
}
h2 {
font-size: 18px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
.page-wrap {
padding: 24px 12px 32px !important;
}
h3 {
font-size: 16px !important;
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
.main-card {
border-radius: 20px !important;
}
.content-wrap {
padding: 10px !important;
padding: 32px 24px 18px !important;
}
}
</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
<table class="body-wrap" style="width: 100%; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
{% assign brand_name = global_config['BRAND_NAME'] %}
{% if brand_name == nil %}
{% assign brand_name = 'Chatwoot' %}
{% endif %}
{% assign brand_url = global_config['BRAND_URL'] %}
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; line-height: 21px; background-color: #F4F7FB; margin: 0; padding: 0;" bgcolor="#F4F7FB">
<table class="body-wrap" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #F4F7FB; margin: 0;" bgcolor="#F4F7FB">
<tr style="margin: 0;">
<td class="container" width="600" style="display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
<div class="content" style="display: block; margin: 0 auto; padding: 20px; text-align:center;">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="border-radius: 6px; background-color: #fff; text-align:left; margin: 0; border: 1px solid #e9e9e9; border-top:3px solid #0080f8;" bgcolor="#fff">
<td align="center" class="page-wrap" style="padding: 32px 16px 40px;" valign="top">
<table class="email-container" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 640px; margin: 0 auto;">
<tr style="margin: 0;">
<td style="margin: 0;">
<table class="main-card" role="presentation" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 24px; background-color: #FFFFFF; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); overflow: hidden;" bgcolor="#FFFFFF">
<tr style="margin: 0;">
<td class="accent-bar" style="height: 6px; background-color: #2781F6; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 40px 36px 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
{{ content_for_layout }}
</table>
</td>
</tr>
</table>
</td>
</tr>
{% if brand_name != '' %}
<tr style="margin: 0;">
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0;">
{{ content_for_layout }}
</table>
<td class="footer" style="padding: 20px 8px 0; text-align: center; color: #64748B; font-size: 13px; line-height: 21px;">
This email was sent by
{% if brand_url != nil and brand_url != '' %}
<a href="{{ brand_url }}" style="color: #2781F6; font-weight: 600; text-decoration: none;">{{ brand_name }}</a>.
{% else %}
<span style="color: #0F172A; font-weight: 600;">{{ brand_name }}</span>.
{% endif %}
</td>
</tr>
</table>
</div>
<div class="footer" style="color: #93AFC8; margin: 0; padding: 0 20px 40px; text-align: center">
<table width="100%" style="margin: 0;">
<tr style="margin: 0;">
{% if global_config['BRAND_NAME'] != '' %}
<td class="content-block" style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Powered by
<a href="{{ global_config['BRAND_URL'] }}" style="vertical-align: top; color: #93AFC8; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
{{ global_config['BRAND_NAME'] }}
</a>
</td>
{% endif %}
</tr>
</table>
</div>
{% endif %}
</table>
</td>
</tr>
</table>
@@ -1,3 +1,6 @@
json.array! @resources do |resource|
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: resource.permissible
bot = resource.permissible
next if bot.nil?
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: bot
end
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
</ul>
@@ -0,0 +1,190 @@
<% content_for(:title) do %>Push Diagnostics<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">Push Diagnostics</h1>
</header>
<section class="main-content__body">
<p class="text-sm text-slate-600 mb-6">
Send a test push notification to a specific user's registered devices to diagnose delivery issues.
Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
</p>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">1. Look up user</h2>
<%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
<%= f.text_field :user_query,
value: @query,
placeholder: 'user@example.com or numeric user ID',
class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
<%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
<% end %>
<% if @query.present? && @user.nil? %>
<p class="text-sm text-red-600 mt-2">No user found for "<%= @query %>".</p>
<% end %>
</div>
<% if @user %>
<div class="mb-6 border border-slate-100 rounded-md p-4 bg-slate-25">
<p class="text-sm">
<strong><%= @user.name %></strong>
&middot; <%= @user.email %>
&middot; ID <%= @user.id %>
</p>
<% if @user.accounts.any? %>
<p class="text-xs text-slate-500 mt-1">
Accounts:
<% @user.accounts.each_with_index do |account, index| %>
<%= ', ' if index.positive? %>
<%= account.name %> (ID <%= account.id %>)
<% end %>
</p>
<% end %>
</div>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">2. Select subscriptions (<%= @subscriptions.count %>)</h2>
<p class="text-xs text-amber-700 mb-3">
⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
</p>
<% if @subscriptions.empty? %>
<p class="text-sm text-slate-600">This user has no push subscriptions registered.</p>
<% else %>
<%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
<%= f.hidden_field :user_id, value: @user.id %>
<div class="mb-4 max-w-2xl">
<div class="mb-3">
<%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_field_tag :push_title,
params[:push_title].presence || Notification::PushTestService.default_title,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<div>
<%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_area_tag :push_body,
params[:push_body].presence || Notification::PushTestService.default_body,
rows: 2,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<p class="text-xs text-slate-400 mt-1">Customers will see this text as a real push notification on their device.</p>
</div>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 align-middle first:!pl-4 last:!pr-4"><input type="checkbox" id="toggle-all"></th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device / endpoint</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device details</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Created</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Last updated</th>
</tr>
</thead>
<tbody>
<% @subscriptions.each do |sub| %>
<% attrs = (sub.subscription_attributes || {}).stringify_keys %>
<% if sub.browser_push? %>
<% endpoint = attrs['endpoint'].to_s %>
<% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %>
<% device_display = host.presence || endpoint.presence || '—' %>
<% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %>
<% else %>
<% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %>
<% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %>
<% end %>
<% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %>
<tr class="border-t border-slate-100 align-middle">
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.id %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.subscription_type %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= device_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= token_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 text-xs align-top">
<% if extra_attrs.present? %>
<% extra_attrs.each do |k, v| %>
<div><span class="text-slate-500"><%= k %>:</span> <span class="font-mono"><%= v.to_s.truncate(40) %></span></div>
<% end %>
<% else %>
<span class="text-slate-400"></span>
<% end %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap"><%= sub.created_at.strftime('%Y-%m-%d %H:%M') %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap">
<%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %>
<span class="text-xs text-slate-400">(<%= time_ago_in_words(sub.updated_at) %> ago)</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<div class="mt-4 flex gap-2">
<%= f.submit 'Send Test Push to Selected',
class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
<%= submit_tag 'Delete Selected Subscriptions',
formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
formmethod: 'post',
data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
</div>
<% end %>
<% end %>
</div>
<% if @results.present? %>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">3. Results</h2>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Sub ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Status</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Details</th>
</tr>
</thead>
<tbody>
<% @results.each do |r| %>
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:id] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:type] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:device] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:token_tail] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%
color = {
success: 'bg-green-100 text-green-800',
failure: 'bg-red-100 text-red-800',
skipped: 'bg-slate-100 text-slate-600'
}[r[:status]]
%>
<span class="px-2 py-0.5 rounded-full text-xs <%= color %>"><%= r[:status] %></span>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all whitespace-pre-wrap"><%= r[:message] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('toggle-all');
if (!toggle) return;
toggle.addEventListener('change', (event) => {
document.querySelectorAll('.subscription-checkbox').forEach((cb) => {
cb.checked = event.target.checked;
});
});
});
</script>
<% end %>
+1
View File
@@ -37,6 +37,7 @@ module Chatwoot
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
config.rails_i18n.enabled_modules = [:pluralization]
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
@@ -0,0 +1,8 @@
# frozen_string_literal: true
other_plural_rule = ->(_count) { :other }
Rails.application.config.after_initialize do
I18n.backend.store_translations(:zh_CN, i18n: { plural: { rule: other_plural_rule } })
I18n.backend.store_translations(:zh_TW, i18n: { plural: { rule: other_plural_rule } })
end
-1
View File
@@ -57,5 +57,4 @@ id:
not_found: "tidak ditemukan"
not_locked: "tidak terkunci"
not_saved:
one: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
other: "%{count} kesalahan mengakibatkan %{resource} ini tidak dapat disimpan:"
-1
View File
@@ -57,5 +57,4 @@ ja:
not_found: "見つかりませんでした"
not_locked: "はロックされていません"
not_saved:
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
-1
View File
@@ -57,5 +57,4 @@ ko:
not_found: "찾을 수 없습니다"
not_locked: "잠겨 있지 않습니다"
not_saved:
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
-1
View File
@@ -57,5 +57,4 @@ ms:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ th:
not_found: "not found"
not_locked: "was not locked"
not_saved:
one: "%{count} errors prohibited this %{resource} from being saved:"
other: "%{count} errors prohibited this %{resource} from being saved:"
-1
View File
@@ -57,5 +57,4 @@ vi:
not_found: "không tìm thấy"
not_locked: "không được khoá"
not_saved:
one: "Có %{count} lỗi được tìm thấy từ %{resource}:"
other: "Có %{count} lỗi được tìm thấy từ %{resource}:"
-1
View File
@@ -57,5 +57,4 @@ zh_CN:
not_found: "找不到"
not_locked: "未锁定"
not_saved:
one: "%{count} 个错误禁止保存 %{resource}"
other: "%{count} 个错误禁止保存 %{resource}"
-1
View File
@@ -57,5 +57,4 @@ zh_TW:
not_found: "找不到。"
not_locked: "並未被鎖定。"
not_saved:
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
+12
View File
@@ -488,6 +488,12 @@ en:
agent_capacity_policy:
inbox_already_assigned: 'Inbox has already been assigned to this policy'
portals:
articles:
captain_not_available: 'Translation requires Captain to be enabled for this account'
locale_not_available: 'Locale not available in this portal'
category_not_found: 'Category not found in this portal'
no_articles_found: 'No articles found to process'
invalid_status: 'Invalid status value'
send_instructions:
email_required: 'Email is required'
invalid_email_format: 'Invalid email format'
@@ -496,3 +502,9 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
no_subscriptions_to_delete: 'Select at least one subscription to delete.'
subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
-4
View File
@@ -435,16 +435,12 @@ id:
button: Buka percakapan
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ja:
button: 会話を開く
time_units:
days:
one: '%{count} 日'
other: '%{count} 日'
hours:
one: '%{count} 時間'
other: '%{count} 時間'
minutes:
one: '%{count} 分'
other: '%{count} 分'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ ko:
button: 대화 열기
time_units:
days:
one: '%{count}일'
other: '%{count}일'
hours:
one: '%{count}시간'
other: '%{count}시간'
minutes:
one: '%{count}분'
other: '%{count}분'
seconds:
one: '%{count}초'
other: '%{count}초'
auto_assignment:
default_policy_name: '기본 정책'
-4
View File
@@ -435,16 +435,12 @@ ms:
button: Open conversation
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ th:
button: เปิดดูการสนทนา
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ vi:
button: Mở cuộc trò chuyện
time_units:
days:
one: '%{count} days'
other: '%{count} days'
hours:
one: '%{count} hours'
other: '%{count} hours'
minutes:
one: '%{count} minutes'
other: '%{count} minutes'
seconds:
one: '%{count} seconds'
other: '%{count} seconds'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_CN:
button: 重新打开会话
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小时'
other: '%{count} 小时'
minutes:
one: '%{count} 分钟'
other: '%{count} 分钟'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: 'Default Policy'
-4
View File
@@ -435,16 +435,12 @@ zh_TW:
button: '開啟對話'
time_units:
days:
one: '%{count} 天'
other: '%{count} 天'
hours:
one: '%{count} 小時'
other: '%{count} 小時'
minutes:
one: '%{count} 分鐘'
other: '%{count} 分鐘'
seconds:
one: '%{count} 秒'
other: '%{count} 秒'
auto_assignment:
default_policy_name: '預設策略'

Some files were not shown because too many files have changed in this diff Show More