Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-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>
|
||||
|
||||
+31
-2
@@ -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>
|
||||
|
||||
+231
-7
@@ -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>
|
||||
|
||||
+249
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+32
-1
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
} %>
|
||||
|
||||
@@ -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;"> </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
|
||||
|
||||
@@ -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
|
||||
@@ -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:"
|
||||
|
||||
@@ -57,5 +57,4 @@ ja:
|
||||
not_found: "見つかりませんでした"
|
||||
not_locked: "はロックされていません"
|
||||
not_saved:
|
||||
one: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
|
||||
other: "%{count} 個のエラーが発生し、 %{resource} を保存できませんでした:"
|
||||
|
||||
@@ -57,5 +57,4 @@ ko:
|
||||
not_found: "찾을 수 없습니다"
|
||||
not_locked: "잠겨 있지 않습니다"
|
||||
not_saved:
|
||||
one: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
|
||||
other: "%{count}개의 오류로 인해 이 %{resource}을(를) 저장할 수 없습니다:"
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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}:"
|
||||
|
||||
@@ -57,5 +57,4 @@ zh_CN:
|
||||
not_found: "找不到"
|
||||
not_locked: "未锁定"
|
||||
not_saved:
|
||||
one: "%{count} 个错误禁止保存 %{resource}:"
|
||||
other: "%{count} 个错误禁止保存 %{resource}:"
|
||||
|
||||
@@ -57,5 +57,4 @@ zh_TW:
|
||||
not_found: "找不到。"
|
||||
not_locked: "並未被鎖定。"
|
||||
not_saved:
|
||||
one: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
|
||||
other: "有 %{count} 個錯誤導致 %{resource} 不能被儲存:"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '기본 정책'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '預設策略'
|
||||
|
||||
@@ -358,6 +358,13 @@ Rails.application.routes.draw do
|
||||
resources :categories do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
namespace :articles do
|
||||
resource :bulk_actions, only: [] do
|
||||
post :translate
|
||||
patch :update_status
|
||||
delete :delete_articles
|
||||
end
|
||||
end
|
||||
resources :articles do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
class RenameCompanyConditionKeyInAutomationRules < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
migrate_automation_rule_conditions
|
||||
migrate_contact_custom_filter_queries
|
||||
end
|
||||
|
||||
def down; end
|
||||
|
||||
private
|
||||
|
||||
def migrate_automation_rule_conditions
|
||||
AutomationRule.find_each do |rule|
|
||||
conditions = rename_company_attribute_key(rule.conditions)
|
||||
|
||||
next if conditions == rule.conditions
|
||||
|
||||
rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
def migrate_contact_custom_filter_queries
|
||||
CustomFilter.contact.find_each do |filter|
|
||||
query = filter.query.deep_dup
|
||||
payload = rename_company_attribute_key(query['payload'])
|
||||
next if payload == query['payload']
|
||||
|
||||
query['payload'] = payload
|
||||
filter.update_column(:query, query) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
|
||||
def rename_company_attribute_key(conditions)
|
||||
return conditions unless conditions.is_a?(Array)
|
||||
|
||||
conditions.map do |condition|
|
||||
next condition unless standard_company_condition?(condition)
|
||||
|
||||
condition.merge('attribute_key' => 'company_name')
|
||||
end
|
||||
end
|
||||
|
||||
def standard_company_condition?(condition)
|
||||
condition['attribute_key'] == 'company' &&
|
||||
condition['custom_attribute_type'].blank? &&
|
||||
condition['attribute_model'].in?([nil, '', 'standard'])
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
|
||||
def translate
|
||||
return unless validate_translate_params?
|
||||
|
||||
duplicates = find_existing_translations
|
||||
if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
|
||||
return render json: {
|
||||
duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
|
||||
}, status: :conflict
|
||||
end
|
||||
|
||||
@articles.find_each do |article|
|
||||
Captain::Articles::TranslateJob.perform_later(
|
||||
Current.account, article.id, @locale, @category&.id, Current.user
|
||||
)
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.permit(:locale, :category_id, :force, ids: [])
|
||||
end
|
||||
|
||||
def validate_translate_params?
|
||||
@locale = permitted_params[:locale]
|
||||
@category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
|
||||
@articles = @portal.articles.where(id: permitted_params[:ids])
|
||||
|
||||
captain_available? && valid_locale? && valid_category? && valid_articles?
|
||||
end
|
||||
|
||||
def find_existing_translations
|
||||
root_ids = @articles.map { |a| Article.find_root_article_id(a) }
|
||||
@portal.articles.where(associated_article_id: root_ids, locale: @locale)
|
||||
end
|
||||
|
||||
def captain_available?
|
||||
return true if Current.account.feature_enabled?('captain_tasks')
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_locale?
|
||||
return true if @portal.config['allowed_locales']&.include?(@locale)
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_category?
|
||||
return true if permitted_params[:category_id].blank?
|
||||
return true if @category.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_articles?
|
||||
return true if @articles.any?
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Captain::Articles::TranslateJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account, article_id, target_locale, target_category_id, user)
|
||||
@account = account
|
||||
@source_article = account.articles.find(article_id)
|
||||
|
||||
target_language = language_name_for(target_locale)
|
||||
|
||||
translated_title = translate(@source_article.title, target_language: target_language, type: :title)
|
||||
translated_content = if @source_article.content.present?
|
||||
translate(@source_article.content, target_language: target_language, type: :content)
|
||||
else
|
||||
@source_article.content
|
||||
end
|
||||
|
||||
existing = find_existing_translation(target_locale)
|
||||
|
||||
if existing
|
||||
existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
|
||||
else
|
||||
create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def translate(text, target_language:, type:)
|
||||
response = Captain::Llm::ArticleTranslationService.new(
|
||||
account: @account, text: text, target_language: target_language, type: type
|
||||
).perform
|
||||
raise "Translation failed: #{response[:error]}" if response[:error]
|
||||
|
||||
response[:message]
|
||||
end
|
||||
|
||||
def find_existing_translation(target_locale)
|
||||
root_id = Article.find_root_article_id(@source_article)
|
||||
@source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
|
||||
end
|
||||
|
||||
def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
|
||||
@source_article.portal.articles.create!(
|
||||
title: translated_title,
|
||||
content: translated_content,
|
||||
description: @source_article.description,
|
||||
category_id: target_category_id,
|
||||
locale: target_locale,
|
||||
author_id: user.id,
|
||||
status: :draft,
|
||||
associated_article_id: Article.find_root_article_id(@source_article)
|
||||
)
|
||||
end
|
||||
|
||||
def language_name_for(locale_code)
|
||||
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
|
||||
language_map[locale_code] || locale_code
|
||||
end
|
||||
end
|
||||
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_standard_faqs(document)
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
|
||||
Captain::Llm::FaqGeneratorService.new(document: document).generate
|
||||
end
|
||||
|
||||
def build_paginated_service(document, options)
|
||||
|
||||
@@ -117,6 +117,10 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def to_llm_metadata
|
||||
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
|
||||
TYPES = %i[title content].freeze
|
||||
|
||||
pattr_initialize [:account!, :text!, :target_language!, :type!]
|
||||
|
||||
def perform
|
||||
raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
|
||||
|
||||
response = make_api_call(model: translation_model, messages: messages)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: response[:message].strip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: text }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
type == :title ? title_system_prompt : content_system_prompt
|
||||
end
|
||||
|
||||
def event_name
|
||||
'article_translation'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def translation_model
|
||||
@translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
|
||||
end
|
||||
|
||||
def title_system_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a professional translator.
|
||||
Translate the following text to #{target_language}.
|
||||
Return only the translated text, no explanations or extra formatting.
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def content_system_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a professional translator. Translate the following content to #{target_language}.
|
||||
The content is markdown that may contain embedded HTML blocks.
|
||||
Rules:
|
||||
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
|
||||
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
|
||||
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
|
||||
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
|
||||
- Keep all image tags (both markdown  and HTML <img>), iframes, and embedded media completely unchanged.
|
||||
- Preserve all line breaks, blank lines, and whitespace patterns.
|
||||
- Return ONLY the translated content, no wrapping or explanations.
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
end
|
||||
@@ -42,7 +42,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config,
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config.merge('timezone' => inbox_timezone),
|
||||
contact: contact_attributes,
|
||||
custom_tools: custom_tools_metadata
|
||||
)
|
||||
@@ -70,6 +70,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
)
|
||||
end
|
||||
|
||||
def inbox_timezone
|
||||
@conversation&.inbox&.timezone.presence || 'UTC'
|
||||
end
|
||||
|
||||
def persist_message(message, message_type = 'assistant')
|
||||
# No need to implement
|
||||
end
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(content, language = 'english', account_id: nil)
|
||||
def initialize(document:)
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
@account_id = account_id
|
||||
@document = document
|
||||
@content = document.content
|
||||
@language = document.account.locale_english_name
|
||||
@account_id = document.account_id
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
]
|
||||
],
|
||||
metadata: document_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
return [] if content.nil?
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages]
|
||||
messages: params[:messages],
|
||||
metadata: document_metadata
|
||||
}
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
metadata: {
|
||||
document_id: @document&.id,
|
||||
start_page: start_page,
|
||||
end_page: end_page,
|
||||
iteration: @iterations_completed + 1
|
||||
}
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
|
||||
**Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
|
||||
|
||||
**Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
|
||||
|
||||
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
|
||||
|
||||
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
|
||||
|
||||
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
|
||||
## Guidelines
|
||||
|
||||
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
|
||||
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
|
||||
- **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
|
||||
- **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
|
||||
- **JSON Validity**: Always return properly formatted, valid JSON
|
||||
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
|
||||
|
||||
## Process
|
||||
1. Read the entire provided content carefully
|
||||
2. Identify all key information points, procedures, and examples
|
||||
3. Create questions that cover each information point
|
||||
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
|
||||
5. Verify that combined FAQs represent the complete original content.
|
||||
6. Format as valid JSON
|
||||
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
|
||||
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
|
||||
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
|
||||
5. Create questions that cover each remaining substantive information point
|
||||
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
|
||||
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
|
||||
8. Format as valid JSON
|
||||
PROMPT
|
||||
end
|
||||
|
||||
@@ -168,6 +175,13 @@ class Captain::Llm::SystemPromptsService
|
||||
[Identity]
|
||||
Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
|
||||
|
||||
[Current Time]
|
||||
Current time: #{format_current_time(config['timezone'])}.
|
||||
|
||||
Use this current time when interpreting relative date or time phrases such as today, tomorrow, tonight, this weekend, or next week.
|
||||
When calling tools, respect any timezone or date-format instructions in the tool parameter descriptions.
|
||||
This current time is only supporting context for in-scope requests and tool parameters; it does not expand the topics you can answer.
|
||||
|
||||
[Response Guideline]
|
||||
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
|
||||
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
|
||||
@@ -293,6 +307,12 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
private
|
||||
|
||||
def format_current_time(timezone)
|
||||
tz = ActiveSupport::TimeZone[timezone] if timezone.present?
|
||||
time = tz ? Time.current.in_time_zone(tz) : Time.current
|
||||
time.strftime('%A, %B %d, %Y %I:%M %p %Z')
|
||||
end
|
||||
|
||||
def build_tools_section(custom_tools)
|
||||
tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
|
||||
<<~TOOLS.strip
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def initialize
|
||||
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
|
||||
@@ -33,16 +34,20 @@ class Captain::Tools::FirecrawlService
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: {
|
||||
onlyMainContent: false,
|
||||
formats: ['markdown'],
|
||||
excludeTags: ['iframe']
|
||||
}
|
||||
scrapeOptions: scrape_options
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def scrape_payload(url)
|
||||
{ url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json
|
||||
{ url: url }.merge(scrape_options).to_json
|
||||
end
|
||||
|
||||
def scrape_options
|
||||
{
|
||||
onlyMainContent: true,
|
||||
formats: ['markdown'],
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
|
||||
@@ -1,45 +1,99 @@
|
||||
<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
|
||||
is_saml_account = account_user&.account&.saml_enabled?
|
||||
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
|
||||
|
||||
<% account_user = @resource&.account_users&.first %>
|
||||
<% is_saml_account = account_user&.account&.saml_enabled? %>
|
||||
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? %>
|
||||
<% if is_saml_account %>
|
||||
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).</p>
|
||||
<p>Your organization uses SSO for secure authentication. You will not need a password to access your account.</p>
|
||||
<% else %>
|
||||
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
|
||||
<% end %>
|
||||
<% 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'
|
||||
|
||||
<% 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 %>
|
||||
<% unless is_saml_account %>
|
||||
<p>Please take a moment and click the link below and activate your account.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
if is_saml_account
|
||||
heading = 'Your access is ready'
|
||||
intro_text = "Your #{brand_name} access is already set up."
|
||||
supporting_text = "Use your organization's Single Sign-On (SSO) portal to access #{brand_name}."
|
||||
action_text = nil
|
||||
action_url = nil
|
||||
info_title = "Sign in with your organization's SSO"
|
||||
info_text =
|
||||
"You won't need a separate password for #{brand_name}. Start from your company identity provider portal."
|
||||
detail_rows = []
|
||||
detail_rows << ['Workspace', account_name] if account_name.present?
|
||||
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
|
||||
else
|
||||
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 = []
|
||||
end
|
||||
elsif invited_user
|
||||
eyebrow = 'Workspace invitation'
|
||||
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
|
||||
|
||||
if is_saml_account
|
||||
intro_text = if account_name.present?
|
||||
"#{inviter.name} invited you to access the #{account_name} workspace on #{brand_name}."
|
||||
else
|
||||
"#{inviter.name} invited you to access #{brand_name}."
|
||||
end
|
||||
supporting_text =
|
||||
"Your organization uses Single Sign-On (SSO), so you won't need to create a separate password."
|
||||
action_text = nil
|
||||
action_url = nil
|
||||
info_title = "Use your organization's SSO portal"
|
||||
info_text = "Continue from your company identity provider portal to access #{brand_name}."
|
||||
detail_rows = [['Invited by', inviter.name]]
|
||||
detail_rows << ['Workspace', account_name] if account_name.present?
|
||||
detail_rows << ['Sign-in method', 'Single Sign-On (SSO)']
|
||||
else
|
||||
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
|
||||
end
|
||||
%>
|
||||
|
||||
<% if @resource.unconfirmed_email.present? %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% elsif @resource.confirmed? %>
|
||||
<% if is_saml_account %>
|
||||
<p>You can now access your account by logging in through your organization's SSO portal.</p>
|
||||
<% else %>
|
||||
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
|
||||
<% end %>
|
||||
<% elsif account_user&.inviter.present? %>
|
||||
<% if is_saml_account %>
|
||||
<p>You can access your account by logging in through your organization's SSO portal.</p>
|
||||
<% else %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
|
||||
<% end %>
|
||||
<% 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
|
||||
} %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user