Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd93992b42 | ||
|
|
a4be38f8f3 | ||
|
|
6fcc888aee | ||
|
|
c27a5916bb | ||
|
|
66609f06fd | ||
|
|
6a262287d2 | ||
|
|
dfd656a7d9 | ||
|
|
b02e732dd1 | ||
|
|
80b132e155 | ||
|
|
32b63ad0c0 | ||
|
|
ac15339456 | ||
|
|
f99187646e | ||
|
|
5ce6e00730 | ||
|
|
f66b551c7d | ||
|
|
c12e1f8348 | ||
|
|
124782938d | ||
|
|
c70a57407d | ||
|
|
fe6368b42e | ||
|
|
352f120c6a | ||
|
|
a585853690 | ||
|
|
64b0ebd8dc | ||
|
|
cf6ee13665 | ||
|
|
41a3ab6dfa | ||
|
|
e82dca9b79 | ||
|
|
f4c3f5095e | ||
|
|
103b48b50f | ||
|
|
5bc8467450 | ||
|
|
b35592b319 | ||
|
|
adf9213ba1 | ||
|
|
80b0326e16 | ||
|
|
11e55d2d2c | ||
|
|
30f23fa70f |
+9
-9
@@ -170,7 +170,7 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.1.2)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -274,8 +274,8 @@ GEM
|
||||
dry-logic (~> 1.5)
|
||||
dry-types (~> 1.8)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.8.3)
|
||||
bigdecimal (~> 3.0)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
@@ -598,14 +598,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3)
|
||||
nokogiri (1.19.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
nokogiri (1.19.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.6)
|
||||
auth-sanitizer (~> 0.2, >= 0.2.1)
|
||||
@@ -627,7 +627,7 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0, >= 2.0.5)
|
||||
version_gem (~> 1.1, >= 1.1.11)
|
||||
oj (3.16.10)
|
||||
oj (3.17.3)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.4)
|
||||
@@ -674,7 +674,7 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.14.2
|
||||
4.15.1
|
||||
|
||||
@@ -6,6 +6,11 @@ class Messages::Messenger::MessageBuilder
|
||||
return if unsupported_file_type?(attachment['type'])
|
||||
|
||||
params = attachment_params(attachment)
|
||||
# During Meta's sticker webhook transition, a sticker message carries both an `image`
|
||||
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
|
||||
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
|
||||
return if duplicate_sticker?(attachment, params[:external_url])
|
||||
|
||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
if facebook_reel?(attachment)
|
||||
@@ -13,10 +18,14 @@ class Messages::Messenger::MessageBuilder
|
||||
elsif params[:remote_file_url]
|
||||
attach_file(attachment_obj, params[:remote_file_url])
|
||||
end
|
||||
fetch_attachment_links(attachment_obj)
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def fetch_attachment_links(attachment_obj)
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
@@ -111,13 +120,20 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
# Facebook may send attachment types that don't directly match our file_type enum.
|
||||
# Map known aliases to their canonical enum values.
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
|
||||
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
|
||||
|
||||
def normalize_file_type(type)
|
||||
sym = type.to_sym
|
||||
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
|
||||
end
|
||||
|
||||
def duplicate_sticker?(attachment, url)
|
||||
return false unless attachment['type'].to_sym == :sticker
|
||||
return false if url.blank?
|
||||
|
||||
@message.attachments.any? { |existing| existing.external_url == url }
|
||||
end
|
||||
|
||||
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
|
||||
# direct video URLs. Downloading these yields HTML, not video content.
|
||||
def facebook_reel?(attachment)
|
||||
|
||||
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
render json: error, status: error_status
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
|
||||
|
||||
@@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_channel_types
|
||||
%w[web_widget api email line telegram whatsapp sms app_store]
|
||||
%w[web_widget api email line telegram whatsapp sms]
|
||||
end
|
||||
|
||||
def update_inbox_working_hours
|
||||
@@ -179,8 +179,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms,
|
||||
'app_store' => Channel::AppStore
|
||||
'sms' => Channel::Sms
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -111,8 +111,7 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels,
|
||||
'app_store' => Current.account.app_store_channels
|
||||
'sms' => Current.account.sms_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -71,11 +71,6 @@ sms:
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
app_store:
|
||||
name: 'App Store Reviews'
|
||||
description: 'Manage your App Store app reviews from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-apps-2-line'
|
||||
messenger:
|
||||
name: 'Messenger'
|
||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class MessageReports extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/message_reports', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageReports();
|
||||
@@ -16,6 +16,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -25,6 +26,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
authorId,
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
|
||||
@@ -145,6 +145,12 @@
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
|
||||
// Voice call widget - light mode
|
||||
--call-widget: 33, 34, 38, 0.95;
|
||||
--call-widget-border: 255, 255, 255, 0.1;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -291,6 +297,12 @@
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
|
||||
// Voice call widget - dark mode
|
||||
--call-widget: 50, 53, 61, 1;
|
||||
--call-widget-border: 255, 255, 255, 0.07;
|
||||
--call-widget-text: 237, 238, 240, 1;
|
||||
--call-widget-sub-text: 173, 177, 184, 1;
|
||||
}
|
||||
}
|
||||
// NEXT COLORS END
|
||||
|
||||
@@ -26,6 +26,13 @@ const resetForm = () => {
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = (company = {}) => {
|
||||
form.name = company.name || '';
|
||||
form.domain = company.domain || '';
|
||||
form.description = company.description || '';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
@@ -45,7 +52,7 @@ const onSuccess = () => {
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef, onSuccess });
|
||||
defineExpose({ dialogRef, onSuccess, open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
// Name of the linked company, so the label shows before the list is loaded.
|
||||
selectedName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isDetailsView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const CREATE_PREFIX = 'create:';
|
||||
|
||||
const options = ref([]);
|
||||
const searchQuery = ref('');
|
||||
const createDialogRef = ref(null);
|
||||
const isCreatingCompany = ref(false);
|
||||
|
||||
const toOption = company => ({ label: company.name, value: company.id });
|
||||
|
||||
const createOption = computed(() => {
|
||||
const name = searchQuery.value.trim();
|
||||
if (!name) return null;
|
||||
|
||||
const exists = options.value.some(
|
||||
option => option.label.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
if (exists) return null;
|
||||
|
||||
return {
|
||||
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
|
||||
value: `${CREATE_PREFIX}${name}`,
|
||||
};
|
||||
});
|
||||
|
||||
const comboboxOptions = computed(() => {
|
||||
const list = [...options.value];
|
||||
|
||||
// Keep the linked company visible even when it is not in the loaded results.
|
||||
if (
|
||||
props.modelValue &&
|
||||
props.selectedName &&
|
||||
!list.some(option => option.value === Number(props.modelValue))
|
||||
) {
|
||||
list.unshift({
|
||||
label: props.selectedName,
|
||||
value: Number(props.modelValue),
|
||||
});
|
||||
}
|
||||
|
||||
if (createOption.value) list.push(createOption.value);
|
||||
return list;
|
||||
});
|
||||
|
||||
const fetchCompanies = async query => {
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = query
|
||||
? await CompanyAPI.search(query)
|
||||
: await CompanyAPI.get({ page: 1 });
|
||||
options.value = (payload || []).map(toOption);
|
||||
} catch {
|
||||
options.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch lazily, only when the dropdown opens, instead of on mount.
|
||||
const handleOpen = () => {
|
||||
searchQuery.value = '';
|
||||
fetchCompanies('');
|
||||
};
|
||||
|
||||
const handleSearch = useDebounceFn(query => {
|
||||
searchQuery.value = query?.trim() || '';
|
||||
fetchCompanies(searchQuery.value);
|
||||
}, 300);
|
||||
|
||||
// Open the create dialog (prefilled with the typed name) so the user can add
|
||||
// domain/description before saving, instead of creating with just a name.
|
||||
const createCompany = async company => {
|
||||
isCreatingCompany.value = true;
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.create({ company });
|
||||
createDialogRef.value?.onSuccess();
|
||||
emit('select', { id: payload.id, name: payload.name });
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
|
||||
} finally {
|
||||
isCreatingCompany.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = value => {
|
||||
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
|
||||
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
|
||||
// Drop the transient "Add …" option so the button label doesn't stick to
|
||||
// it if the dialog is dismissed without creating.
|
||||
searchQuery.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const id = value ? Number(value) : '';
|
||||
const selected = comboboxOptions.value.find(option => option.value === id);
|
||||
emit('select', { id, name: selected?.label || '' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboBox
|
||||
:model-value="modelValue"
|
||||
:options="comboboxOptions"
|
||||
:display-label="selectedName"
|
||||
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
|
||||
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
|
||||
use-api-results
|
||||
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
|
||||
:class="{
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
|
||||
!isDetailsView,
|
||||
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
|
||||
}"
|
||||
@open="handleOpen"
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSelect"
|
||||
/>
|
||||
<CompanyCreateDialog
|
||||
ref="createDialogRef"
|
||||
:is-loading="isCreatingCompany"
|
||||
@create="createCompany"
|
||||
/>
|
||||
</template>
|
||||
@@ -15,6 +15,7 @@ const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
companyId: { type: [Number, String], default: '' },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
@@ -41,6 +42,7 @@ const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
email: props.email,
|
||||
companyId: props.companyId,
|
||||
phoneNumber: props.phoneNumber,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
});
|
||||
|
||||
@@ -5,8 +5,11 @@ import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
|
||||
|
||||
@@ -28,6 +31,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const FORM_CONFIG = {
|
||||
FIRST_NAME: { field: 'firstName' },
|
||||
@@ -54,6 +58,7 @@ const defaultState = {
|
||||
id: 0,
|
||||
name: '',
|
||||
email: '',
|
||||
companyId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
@@ -85,6 +90,23 @@ const validationRules = {
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||
const hasCompaniesFeature = computed(
|
||||
() =>
|
||||
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
|
||||
);
|
||||
const showCompanySelector = computed(
|
||||
() =>
|
||||
hasCompaniesFeature.value &&
|
||||
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
|
||||
);
|
||||
|
||||
const emitContactUpdate = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
const { firstName, lastName, ...stateWithoutNames } = state;
|
||||
emit('update', stateWithoutNames);
|
||||
};
|
||||
|
||||
const prepareStateBasedOnProps = () => {
|
||||
if (props.isNewContact) {
|
||||
@@ -96,6 +118,7 @@ const prepareStateBasedOnProps = () => {
|
||||
name = '',
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
companyId = '',
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
@@ -115,6 +138,7 @@ const prepareStateBasedOnProps = () => {
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
companyId: companyId || '',
|
||||
firstName,
|
||||
lastName,
|
||||
email: emailAddress,
|
||||
@@ -200,11 +224,7 @@ const getFormBinding = key => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (isFormValid) {
|
||||
const { firstName, lastName, ...stateWithoutNames } = state;
|
||||
emit('update', stateWithoutNames);
|
||||
}
|
||||
await emitContactUpdate();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -221,6 +241,12 @@ const handleCountrySelection = value => {
|
||||
emit('update', state);
|
||||
};
|
||||
|
||||
const handleCompanySelection = async ({ id, name }) => {
|
||||
state.companyId = id || '';
|
||||
state.additionalAttributes.companyName = name || '';
|
||||
await emitContactUpdate();
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
v$.value.$reset();
|
||||
};
|
||||
@@ -273,6 +299,13 @@ defineExpose({
|
||||
:placeholder="item.placeholder"
|
||||
:show-border="isDetailsView"
|
||||
/>
|
||||
<CompanySelector
|
||||
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
|
||||
:model-value="state.companyId"
|
||||
:selected-name="state.additionalAttributes.companyName"
|
||||
:is-details-view="isDetailsView"
|
||||
@select="handleCompanySelection"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="getFormBinding(item.key).value"
|
||||
|
||||
@@ -92,6 +92,7 @@ const handleAvatarHover = (id, isHovered) => {
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:company-id="contact.companyId"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
|
||||
@@ -210,9 +210,9 @@ const handleClick = id => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
|
||||
<div class="flex items-center min-w-0 gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
@@ -223,19 +223,17 @@ const handleClick = id => {
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
|
||||
>
|
||||
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
|
||||
<EmojiIcon
|
||||
v-if="category?.icon"
|
||||
:value="category.icon"
|
||||
:color="category.icon_color"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
{{ categoryName }}
|
||||
<span class="truncate">{{ categoryName }}</span>
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
@@ -247,7 +245,7 @@ const handleClick = id => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
|
||||
>
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<div v-if="activePortalName" class="relative shrink-0 group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
@@ -91,6 +91,9 @@ const togglePortalSwitcher = () => {
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
<div class="flex justify-end min-w-0 grow">
|
||||
<slot name="title-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,10 @@ const props = defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
@@ -41,6 +45,7 @@ const hoveredArticleId = ref(null);
|
||||
const dragEnabled = computed(() => {
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
|
||||
+32
-2
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -18,6 +19,7 @@ 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 Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
@@ -49,7 +51,12 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
const emit = defineEmits([
|
||||
'pageChange',
|
||||
'fetchPortal',
|
||||
'refreshArticles',
|
||||
'search',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -65,6 +72,9 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -122,6 +132,7 @@ const updateRoute = newParams => {
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
query: route.query,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -145,7 +156,12 @@ const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (isSearching.value) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
|
||||
}
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
@@ -291,6 +307,19 @@ watch(
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #title-actions>
|
||||
<Input
|
||||
v-if="!isSwitchingPortal"
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full max-w-[16rem] min-w-0"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<ArticleHeaderControls
|
||||
@@ -422,6 +451,7 @@ watch(
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@@ -433,7 +463,7 @@ watch(
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:show-button="hasNoArticlesInPortal && !isSearching"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
|
||||
+21
-2
@@ -37,10 +37,21 @@ const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.categories;
|
||||
return props.categories.filter(category =>
|
||||
category.name?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategories = computed(() => filteredCategories.value?.length > 0);
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
@@ -115,6 +126,7 @@ const reorderCategories = async reorderedGroup => {
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
v-model:search-query="searchQuery"
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@@ -130,11 +142,18 @@ const reorderCategories = async reorderedGroup => {
|
||||
</div>
|
||||
<CategoryList
|
||||
v-else-if="hasCategories"
|
||||
:categories="categories"
|
||||
:categories="filteredCategories"
|
||||
:disable-drag="isSearching"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else-if="isSearching"
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
|
||||
+34
-17
@@ -6,6 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
|
||||
@@ -27,6 +28,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
|
||||
const searchQuery = defineModel('searchQuery', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const getters = useStoreGetters();
|
||||
@@ -162,23 +168,34 @@ const handleBreadcrumbClick = () => {
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<div v-if="!hasSelectedCategory" class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
|
||||
+5
-1
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableDrag: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
@@ -15,7 +19,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
return !props.disableDrag && localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
|
||||
+42
-7
@@ -2,8 +2,12 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
@@ -19,7 +23,10 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
@@ -28,6 +35,19 @@ const openAddLocaleDialog = () => {
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
|
||||
const filteredLocales = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return props.locales;
|
||||
return props.locales.filter(
|
||||
locale =>
|
||||
locale.name?.toLowerCase().includes(query) ||
|
||||
locale.code?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0);
|
||||
const hasResults = computed(() => filteredLocales.value?.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,12 +59,21 @@ const localeCount = computed(() => props.locales?.length);
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-48"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -54,7 +83,13 @@ const localeCount = computed(() => props.locales?.length);
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
<EmptyStateLayout
|
||||
v-else-if="isSearching && !hasResults"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
/>
|
||||
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
|
||||
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
|
||||
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
|
||||
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
|
||||
>
|
||||
<!-- Top section: status badge + location/inbox + duration -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<!-- Ongoing: status badge on left -->
|
||||
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
|
||||
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
|
||||
<Icon
|
||||
v-else-if="!isOngoing"
|
||||
:icon="channelIcon"
|
||||
class="size-3.5 text-n-slate-10 shrink-0"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
|
||||
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
|
||||
>
|
||||
{{ callInfo.location }}
|
||||
</span>
|
||||
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
|
||||
<!-- Ongoing: duration on right -->
|
||||
<p
|
||||
v-if="isOngoing"
|
||||
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
|
||||
>
|
||||
{{ duration }}
|
||||
</p>
|
||||
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
class="!rounded-full -my-1"
|
||||
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
|
||||
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.contactName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="callInfo.phoneNumber"
|
||||
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
|
||||
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
|
||||
>
|
||||
{{ callInfo.phoneNumber }}
|
||||
</p>
|
||||
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
|
||||
>
|
||||
<template #icon>
|
||||
<span
|
||||
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
|
||||
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
|
||||
>
|
||||
<Icon
|
||||
icon="i-ph-chat-circle-text-bold"
|
||||
class="size-3.5 text-n-slate-11 shrink-0"
|
||||
class="size-3.5 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
<span class="text-sm tracking-tight tabular-nums">
|
||||
#{{ call.conversationId }}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-ph-caret-right-bold"
|
||||
class="size-3 text-n-slate-11 shrink-0"
|
||||
class="size-3 text-n-call-widget-sub-text shrink-0"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
|
||||
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,9 @@ const props = defineProps({
|
||||
value.every(option => 'value' in option && 'label' in option),
|
||||
},
|
||||
placeholder: { type: String, default: '' },
|
||||
// Fallback label shown when the selected value is not in `options` yet
|
||||
// (e.g. API-backed lists that load lazily on open).
|
||||
displayLabel: { type: String, default: '' },
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchPlaceholder: { type: String, default: '' },
|
||||
@@ -23,7 +26,7 @@ const props = defineProps({
|
||||
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search']);
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -52,7 +55,7 @@ const selectedLabel = computed(() => {
|
||||
const selected = props.options.find(
|
||||
option => option.value === selectedValue.value
|
||||
);
|
||||
return selected?.label ?? selectPlaceholder.value;
|
||||
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
@@ -72,6 +75,7 @@ const toggleDropdown = () => {
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
emit('open');
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ defineExpose({
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@click="emit('select', option)"
|
||||
@click.stop="emit('select', option)"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
|
||||
@@ -14,7 +14,6 @@ const channelTypeIconMap = {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::AppStore': 'i-ri-app-store-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -147,8 +147,14 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const isCaptainMessage = computed(() => {
|
||||
const senderType = props.sender?.type ?? props.senderType;
|
||||
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
|
||||
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
report:
|
||||
isOnChatwootCloud.value &&
|
||||
isCaptainMessage.value &&
|
||||
!isMessageDeleted.value,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAnAppStoreChannel,
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
@@ -63,8 +62,7 @@ const isSent = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -87,8 +85,7 @@ const isDelivered = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
@@ -113,8 +110,7 @@ const isRead = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
@@ -9,11 +11,28 @@ const { attachments } = useMessageContext();
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
mediaType: 'audio',
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
{{ $t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<AudioChip
|
||||
v-else-if="isLoaded"
|
||||
:attachment="attachment"
|
||||
class="p-2 text-n-slate-12 skip-context-menu"
|
||||
/>
|
||||
|
||||
@@ -64,10 +64,6 @@ const isActive = computed(() => {
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'app_store') {
|
||||
return props.enabledFeatures.channel_app_store;
|
||||
}
|
||||
|
||||
return [
|
||||
'website',
|
||||
'twilio',
|
||||
@@ -90,9 +86,7 @@ const isComingSoon = computed(() => {
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice', 'whatsapp_call', 'app_store'].includes(
|
||||
props.channel.key
|
||||
);
|
||||
return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key);
|
||||
});
|
||||
|
||||
const hasVoiceBadge = computed(() => {
|
||||
|
||||
@@ -36,7 +36,6 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -245,29 +244,6 @@ export default {
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
hasSubmittedAppStoreResponse() {
|
||||
const messages = this.currentChat.messages || [];
|
||||
return messages.some(message => {
|
||||
const appStoreAttributes =
|
||||
message.content_attributes?.app_store ||
|
||||
message.contentAttributes?.appStore ||
|
||||
{};
|
||||
const hasAppStoreResponse =
|
||||
message.source_id || appStoreAttributes.response_id;
|
||||
|
||||
return (
|
||||
message.message_type === MESSAGE_TYPE.OUTGOING && hasAppStoreResponse
|
||||
);
|
||||
});
|
||||
},
|
||||
appStoreReplyBannerMessage() {
|
||||
return this.hasSubmittedAppStoreResponse
|
||||
? this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_UPDATE')
|
||||
: this.$t('CONVERSATION.REPLYBOX.APP_STORE_RESPONSE_DELAY');
|
||||
},
|
||||
showAppStoreReplyBanner() {
|
||||
return this.isAnAppStoreChannel;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -492,12 +468,6 @@ export default {
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showAppStoreReplyBanner"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="appStoreReplyBannerMessage"
|
||||
/>
|
||||
</div>
|
||||
<MessageList
|
||||
ref="conversationPanelRef"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLoadWithRetry = (config = {}) => {
|
||||
const maxRetry = config.max_retry || 3;
|
||||
const backoff = config.backoff || 1000;
|
||||
const maxRetry = (config.max_retry ?? config.maxRetry) || 3;
|
||||
const backoff = (config.backoff ?? config.backOff) || 1000;
|
||||
const mediaType = (config.mediaType ?? config.media_type) || 'image';
|
||||
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
@@ -10,19 +11,25 @@ export const useLoadWithRetry = (config = {}) => {
|
||||
const loadWithRetry = async url => {
|
||||
const attemptLoad = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const onSuccess = () => {
|
||||
isLoaded.value = true;
|
||||
hasError.value = false;
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
let element;
|
||||
if (mediaType === 'audio') {
|
||||
element = new Audio();
|
||||
element.preload = 'metadata';
|
||||
element.onloadedmetadata = onSuccess;
|
||||
} else {
|
||||
element = new Image();
|
||||
element.onload = onSuccess;
|
||||
}
|
||||
element.onerror = () => reject(new Error('Failed to load resource'));
|
||||
const cacheBustedUrl = new URL(url);
|
||||
cacheBustedUrl.searchParams.set('t', Date.now());
|
||||
element.src = cacheBustedUrl.toString();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
@@ -31,7 +30,6 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
@@ -140,10 +138,6 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAnAppStoreChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.APP_STORE;
|
||||
});
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
@@ -166,7 +160,6 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAnAppStoreChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
|
||||
@@ -116,11 +116,6 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::AppStore': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::PrivateNote': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
|
||||
@@ -41,8 +41,6 @@ export default {
|
||||
DOCS_URL: 'https://www.chatwoot.com/docs/product/',
|
||||
HELP_CENTER_DOCS_URL:
|
||||
'https://www.chatwoot.com/docs/product/others/help-center',
|
||||
APP_STORE_REVIEWS_INBOX_DOCS_URL:
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/app-store-reviews-inbox',
|
||||
TESTIMONIAL_URL:
|
||||
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
|
||||
@@ -11,7 +11,6 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
APP_STORE: 'Channel::AppStore',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
@@ -54,7 +53,6 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -70,7 +68,6 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||
@@ -120,9 +117,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.APP_STORE:
|
||||
return 'app_store';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"ACTIONS": {
|
||||
"CREATE": "Add company"
|
||||
},
|
||||
"SELECTOR": {
|
||||
"PLACEHOLDER": "Select company",
|
||||
"CREATE_OPTION": "Add \"{name}\""
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Add company details",
|
||||
"ACTIONS": {
|
||||
|
||||
@@ -237,8 +237,6 @@
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"APP_STORE_RESPONSE_DELAY": "Apple may take up to 24 hours to make submitted replies or updates public.",
|
||||
"APP_STORE_RESPONSE_UPDATE": "This review already has a reply. Apple may take up to 24 hours to make submitted replies or updates public. Sending another reply updates the existing public App Store response.",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
@@ -306,6 +304,25 @@
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"REPORT_MESSAGE": {
|
||||
"LABEL": "Report message",
|
||||
"TITLE": "Report Captain message",
|
||||
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
|
||||
"PROBLEM_TYPE": "Problem type",
|
||||
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
|
||||
"DESCRIPTION_LABEL": "Description",
|
||||
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
|
||||
"SUBMIT": "Report",
|
||||
"SUCCESS": "Thanks for reporting. Our team will take a look.",
|
||||
"ERROR": "Could not report this message. Please try again.",
|
||||
"REASONS": {
|
||||
"incorrect_information": "Incorrect information",
|
||||
"inappropriate_response": "Inappropriate response",
|
||||
"incomplete_response": "Incomplete response",
|
||||
"outdated_information": "Outdated information",
|
||||
"other": "Other"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
|
||||
@@ -552,6 +552,7 @@
|
||||
"LOCALE": {
|
||||
"ALL": "All locales"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search articles...",
|
||||
"NEW_ARTICLE": "New article"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
@@ -579,6 +580,10 @@
|
||||
"CATEGORY": {
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "No matching articles",
|
||||
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
@@ -624,6 +629,7 @@
|
||||
"CATEGORY_HEADER": {
|
||||
"NEW_CATEGORY": "New category",
|
||||
"EDIT_CATEGORY": "Edit category",
|
||||
"SEARCH_PLACEHOLDER": "Search categories...",
|
||||
"CATEGORIES_COUNT": "{n} category | {n} categories",
|
||||
"BREADCRUMB": {
|
||||
"CATEGORY_LOCALE": "Categories ({localeCode})",
|
||||
@@ -634,6 +640,10 @@
|
||||
"TITLE": "No categories found",
|
||||
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
|
||||
},
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching categories",
|
||||
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
|
||||
},
|
||||
"CATEGORY_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles"
|
||||
},
|
||||
@@ -691,6 +701,11 @@
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "New locale",
|
||||
"SEARCH_PLACEHOLDER": "Search locales...",
|
||||
"SEARCH_EMPTY_STATE": {
|
||||
"TITLE": "No matching locales",
|
||||
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
|
||||
},
|
||||
"LOCALE_CARD": {
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
|
||||
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||
@@ -384,40 +385,6 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the api channel"
|
||||
}
|
||||
},
|
||||
"APP_STORE": {
|
||||
"TITLE": "App Store Reviews",
|
||||
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
|
||||
"LEARN_MORE": "Learn how to create App Store Connect credentials",
|
||||
"CHANNEL_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"APP_ID": {
|
||||
"LABEL": "App Store Connect app ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect app ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"ISSUER_ID": {
|
||||
"LABEL": "Issuer ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect issuer ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"KEY_ID": {
|
||||
"LABEL": "Key ID",
|
||||
"PLACEHOLDER": "Enter your App Store Connect key ID",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"PRIVATE_KEY": {
|
||||
"LABEL": "Private key",
|
||||
"PLACEHOLDER": "Paste the contents of your .p8 private key",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create App Store Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the App Store channel"
|
||||
}
|
||||
},
|
||||
"EMAIL_CHANNEL": {
|
||||
"TITLE": "Email Channel",
|
||||
"DESC": "Integrate your email inbox.",
|
||||
@@ -533,10 +500,6 @@
|
||||
"TITLE": "TikTok",
|
||||
"DESCRIPTION": "Connect your TikTok account"
|
||||
},
|
||||
"APP_STORE": {
|
||||
"TITLE": "App Store Reviews",
|
||||
"DESCRIPTION": "Manage your App Store app reviews"
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice",
|
||||
"DESCRIPTION": "Integrate with Twilio Voice"
|
||||
@@ -1238,7 +1201,6 @@
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"TIKTOK": "TikTok",
|
||||
"APP_STORE": "App Store Reviews",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
},
|
||||
"MEDIA": {
|
||||
"IMAGE_UNAVAILABLE": "This image is no longer available.",
|
||||
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
|
||||
"LOADING_FAILED": "Loading failed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
NextButton,
|
||||
ReportCaptainMessageDialog,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
@@ -152,6 +154,10 @@ export default {
|
||||
closeDeleteModal() {
|
||||
this.showDeleteModal = false;
|
||||
},
|
||||
openReportDialog() {
|
||||
this.handleClose();
|
||||
this.$refs.reportDialog?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -243,6 +249,16 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['report']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['report']"
|
||||
:option="{
|
||||
icon: 'warning',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openReportDialog"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
@@ -255,6 +271,11 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
<ReportCaptainMessageDialog
|
||||
v-if="enabledOptions['report']"
|
||||
ref="reportDialog"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: [Number, String], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const REPORT_REASONS = [
|
||||
'incorrect_information',
|
||||
'inappropriate_response',
|
||||
'incomplete_response',
|
||||
'outdated_information',
|
||||
'other',
|
||||
];
|
||||
|
||||
const reasonOptions = computed(() =>
|
||||
REPORT_REASONS.map(value => ({
|
||||
value,
|
||||
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const form = reactive({ reportReason: '', description: '' });
|
||||
|
||||
const isFormInvalid = computed(() => !form.reportReason);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportReason = '';
|
||||
form.description = '';
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isFormInvalid.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await MessageReportsAPI.create({
|
||||
message_id: props.messageId,
|
||||
report_reason: form.reportReason,
|
||||
description: form.description.trim() || null,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
|
||||
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
|
||||
:is-loading="isLoading"
|
||||
:disable-confirm-button="isFormInvalid"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="form.reportReason"
|
||||
class="!w-full [&>select]:w-full"
|
||||
:options="reasonOptions"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="form.description"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
auto-height
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { ROLES } from 'dashboard/constants/permissions';
|
||||
@@ -30,16 +30,6 @@ const onSelectRecentSearch = query => {
|
||||
searchQuery.value = query;
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.initialQuery,
|
||||
newValue => {
|
||||
if (searchQuery.value !== newValue) {
|
||||
searchQuery.value = newValue;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -30,10 +30,13 @@ const debouncedEmit = debounce(
|
||||
500
|
||||
);
|
||||
|
||||
const onInput = () => {
|
||||
debouncedEmit(searchQuery.value);
|
||||
const onInput = e => {
|
||||
// Use the DOM value, not searchQuery.value: the defineModel ref updates a tick
|
||||
// later, so reading it back here lags one character behind.
|
||||
const value = e.target.value;
|
||||
debouncedEmit(value);
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
if (value.trim()) {
|
||||
showRecentSearches.value = false;
|
||||
} else if (isInputFocused.value) {
|
||||
showRecentSearches.value = true;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tabs: {
|
||||
@@ -17,8 +15,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['tabChange']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref(props.selectedTab);
|
||||
|
||||
watch(
|
||||
@@ -51,14 +47,5 @@ const onTabChange = selectedTab => {
|
||||
:initial-active-tab="activeTab"
|
||||
@tab-changed="onTabChange"
|
||||
/>
|
||||
|
||||
<Button
|
||||
:label="t('SEARCH.SORT_BY.RELEVANCE')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
||||
@@ -28,6 +29,7 @@ import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PER_PAGE = 15; // Results per page
|
||||
@@ -338,19 +340,26 @@ const onTabChange = tab => {
|
||||
onMounted(() => {
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
store.dispatch('agents/get');
|
||||
|
||||
const parsedFilters = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
filters.value = parsedFilters;
|
||||
|
||||
// Auto-execute search if query parameter exists
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
|
||||
// derives from account.features (loaded async), and reading it too early strips
|
||||
// the filter params from the URL. `immediate` covers the already-loaded case.
|
||||
watch(
|
||||
() => currentAccount.value?.id,
|
||||
id => {
|
||||
if (!id) return;
|
||||
filters.value = parseURLParams(
|
||||
route.query,
|
||||
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
||||
);
|
||||
if (route.query.q) {
|
||||
onSearch(route.query.q);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
query.value = '';
|
||||
store.dispatch('conversationSearch/clearSearchResults');
|
||||
|
||||
@@ -126,7 +126,7 @@ const showCompany = companyId => {
|
||||
};
|
||||
|
||||
const openCreateCompanyDialog = () => {
|
||||
createCompanyDialogRef.value?.dialogRef.open();
|
||||
createCompanyDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const createCompany = async company => {
|
||||
|
||||
+14
-1
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
@@ -74,6 +76,7 @@ const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
status: status.value,
|
||||
authorId: author.value,
|
||||
categorySlug: selectedCategorySlug.value,
|
||||
query: searchQuery.value || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -81,6 +84,15 @@ const onPageChange = pageNumberParam => {
|
||||
fetchArticles({ pageNumber: pageNumberParam });
|
||||
};
|
||||
|
||||
const onSearch = query => {
|
||||
searchQuery.value = query;
|
||||
pageNumber.value = 1;
|
||||
router.replace({
|
||||
query: { ...route.query, search: query || undefined },
|
||||
});
|
||||
fetchArticles({ pageNumber: 1 });
|
||||
};
|
||||
|
||||
const fetchPortalAndItsCategories = async locale => {
|
||||
await store.dispatch('portals/index');
|
||||
const selectedPortalParam = {
|
||||
@@ -118,6 +130,7 @@ watch(
|
||||
:portal-meta="portalMeta"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
@page-change="onPageChange"
|
||||
@search="onSearch"
|
||||
@fetch-portal="fetchPortalAndItsCategories"
|
||||
@refresh-articles="fetchArticles"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import Telegram from './channels/Telegram.vue';
|
||||
import Instagram from './channels/Instagram.vue';
|
||||
import Tiktok from './channels/Tiktok.vue';
|
||||
import Voice from './channels/Voice.vue';
|
||||
import AppStore from './channels/AppStore.vue';
|
||||
|
||||
const channelViewList = {
|
||||
facebook: Facebook,
|
||||
@@ -28,7 +27,6 @@ const channelViewList = {
|
||||
telegram: Telegram,
|
||||
instagram: Instagram,
|
||||
tiktok: Tiktok,
|
||||
app_store: AppStore,
|
||||
voice: Voice,
|
||||
};
|
||||
|
||||
|
||||
@@ -77,12 +77,6 @@ const channelList = computed(() => {
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
|
||||
icon: 'i-woot-instagram',
|
||||
},
|
||||
{
|
||||
key: 'app_store',
|
||||
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'),
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'),
|
||||
icon: 'i-ri-app-store-line',
|
||||
},
|
||||
];
|
||||
|
||||
if (hasTiktokConfigured.value) {
|
||||
|
||||
@@ -267,13 +267,6 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAnAppStoreChannel) {
|
||||
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
|
||||
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
|
||||
tab => !unsupportedKeys.includes(tab.key)
|
||||
);
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -307,9 +300,6 @@ export default {
|
||||
if (this.isAnEmailChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.email})`;
|
||||
}
|
||||
if (this.isAnAppStoreChannel && this.inbox.app_id) {
|
||||
return `${this.inbox.name} (${this.inbox.app_id})`;
|
||||
}
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
@@ -321,7 +311,6 @@ export default {
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isAnAppStoreChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -858,7 +847,6 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAnAppStoreChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1158,7 +1146,6 @@ export default {
|
||||
</SettingsAccordion>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="!isAnAppStoreChannel"
|
||||
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
|
||||
class="mt-6"
|
||||
>
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import router from '../../../../index';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import globalConstants from 'dashboard/constants/globals';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const channelName = ref('');
|
||||
const appId = ref('');
|
||||
const issuerId = ref('');
|
||||
const keyId = ref('');
|
||||
const privateKey = ref('');
|
||||
const appStoreReviewsInboxDocsUrl =
|
||||
globalConstants.APP_STORE_REVIEWS_INBOX_DOCS_URL;
|
||||
|
||||
const uiFlags = computed(() => store.getters['inboxes/getUIFlags']);
|
||||
|
||||
const rules = {
|
||||
channelName: { required },
|
||||
appId: { required },
|
||||
issuerId: { required },
|
||||
keyId: { required },
|
||||
privateKey: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, {
|
||||
channelName,
|
||||
appId,
|
||||
issuerId,
|
||||
keyId,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
const createChannel = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
try {
|
||||
const appStoreChannel = await store.dispatch('inboxes/createChannel', {
|
||||
name: channelName.value?.trim(),
|
||||
channel: {
|
||||
type: 'app_store',
|
||||
app_id: appId.value.trim(),
|
||||
issuer_id: issuerId.value.trim(),
|
||||
key_id: keyId.value.trim(),
|
||||
private_key: privateKey.value.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: appStoreChannel.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
|
||||
/>
|
||||
<a
|
||||
:href="appStoreReviewsInboxDocsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mb-5 text-sm font-medium text-n-brand"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.LEARN_MORE') }}
|
||||
<Icon icon="i-lucide-external-link" class="size-4" />
|
||||
</a>
|
||||
<form
|
||||
class="flex flex-wrap flex-col mx-0"
|
||||
@submit.prevent="createChannel()"
|
||||
>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.channelName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }}
|
||||
<input
|
||||
v-model="channelName"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.channelName.$touch"
|
||||
/>
|
||||
<span v-if="v$.channelName.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.appId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }}
|
||||
<input
|
||||
v-model="appId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.APP_ID.PLACEHOLDER')"
|
||||
@blur="v$.appId.$touch"
|
||||
/>
|
||||
<span v-if="v$.appId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.issuerId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }}
|
||||
<input
|
||||
v-model="issuerId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.PLACEHOLDER')"
|
||||
@blur="v$.issuerId.$touch"
|
||||
/>
|
||||
<span v-if="v$.issuerId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.keyId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }}
|
||||
<input
|
||||
v-model="keyId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.PLACEHOLDER')"
|
||||
@blur="v$.keyId.$touch"
|
||||
/>
|
||||
<span v-if="v$.keyId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.privateKey.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }}
|
||||
<textarea
|
||||
v-model="privateKey"
|
||||
rows="8"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.privateKey.$touch"
|
||||
/>
|
||||
<span v-if="v$.privateKey.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
type="submit"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.APP_STORE.SUBMIT_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,12 @@ import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const hasError = ref(false);
|
||||
const errorStateMessage = ref('');
|
||||
@@ -56,23 +60,37 @@ const requestAuthorization = async () => {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
class="flex flex-col items-center justify-center w-full px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
|
||||
>
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<div class="flex flex-col items-center w-full max-w-2xl">
|
||||
<h6 class="text-2xl font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
|
||||
</h6>
|
||||
<p class="max-w-xl py-6 text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
|
||||
lg
|
||||
icon="i-ri-tiktok-line"
|
||||
:disabled="isRequestingAuthorization"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
|
||||
@click="requestAuthorization()"
|
||||
/>
|
||||
<Banner v-if="isOnChatwootCloud" color="amber" class="w-full mt-6">
|
||||
<div class="flex items-start gap-3 text-left">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.TIKTOK.NORTH_AMERICA_WARNING') }}
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ const i18nMap = {
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Tiktok': 'TIKTOK',
|
||||
'Channel::AppStore': 'APP_STORE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
|
||||
@@ -13,8 +13,12 @@ const buildContactFormData = contactParams => {
|
||||
const formData = new FormData();
|
||||
const { additional_attributes = {}, ...contactProperties } = contactParams;
|
||||
Object.keys(contactProperties).forEach(key => {
|
||||
if (contactProperties[key]) {
|
||||
formData.append(key, contactProperties[key]);
|
||||
const value = contactProperties[key];
|
||||
const shouldAppendBlankCompanyId =
|
||||
key === 'company_id' && value !== undefined;
|
||||
|
||||
if (value || shouldAppendBlankCompanyId) {
|
||||
formData.append(key, value ?? '');
|
||||
}
|
||||
});
|
||||
const { social_profiles, ...additionalAttributesProperties } =
|
||||
|
||||
@@ -7,7 +7,7 @@ import types from '../../mutation-types';
|
||||
export const actions = {
|
||||
index: async (
|
||||
{ commit },
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
|
||||
{ pageNumber, portalSlug, locale, status, authorId, categorySlug, query }
|
||||
) => {
|
||||
try {
|
||||
commit(types.SET_UI_FLAG, { isFetching: true });
|
||||
@@ -18,6 +18,7 @@ export const actions = {
|
||||
status,
|
||||
authorId,
|
||||
categorySlug,
|
||||
query,
|
||||
});
|
||||
const payload = camelcaseKeys(data.payload);
|
||||
const meta = camelcaseKeys(data.meta);
|
||||
|
||||
@@ -156,8 +156,7 @@ export const getters = {
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item =>
|
||||
![INBOX_TYPES.EMAIL, INBOX_TYPES.APP_STORE].includes(item.channel_type)
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
);
|
||||
},
|
||||
getFacebookInboxByInstagramId: $state => instagramId => {
|
||||
|
||||
@@ -145,6 +145,33 @@ describe('#actions', () => {
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves blank company_id when updating with form data', async () => {
|
||||
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
|
||||
await actions.update(
|
||||
{ commit },
|
||||
{
|
||||
id: contactList[0].id,
|
||||
isFormData: true,
|
||||
name: contactList[0].name,
|
||||
companyId: '',
|
||||
avatar: 'avatar-file',
|
||||
additionalAttributes: {
|
||||
companyName: '',
|
||||
socialProfiles: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const lastPatchCall =
|
||||
axios.patch.mock.calls[axios.patch.mock.calls.length - 1];
|
||||
const formData = lastPatchCall[1];
|
||||
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.has('company_id')).toBe(true);
|
||||
expect(formData.get('company_id')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
@@ -24,7 +23,6 @@ export const INBOX_FEATURE_MAP = {
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.TIKTOK,
|
||||
INBOX_TYPES.APP_STORE,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
@@ -121,8 +119,6 @@ export default {
|
||||
badgeKey = 'whatsapp';
|
||||
} else if (this.isATiktokChannel) {
|
||||
badgeKey = 'tiktok';
|
||||
} else if (this.isAnAppStoreChannel) {
|
||||
badgeKey = 'app_store';
|
||||
}
|
||||
return badgeKey || this.channelType;
|
||||
},
|
||||
@@ -138,9 +134,6 @@ export default {
|
||||
isATiktokChannel() {
|
||||
return this.channelType === INBOX_TYPES.TIKTOK;
|
||||
},
|
||||
isAnAppStoreChannel() {
|
||||
return this.channelType === INBOX_TYPES.APP_STORE;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inboxHasFeature(feature) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform
|
||||
Inbox.includes(:account, :channel).where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
|
||||
account = inbox.account
|
||||
channel = inbox.channel
|
||||
|
||||
next if account.suspended?
|
||||
next unless account.feature_enabled?(:channel_app_store)
|
||||
next unless channel.sync_due?
|
||||
|
||||
::Inboxes::FetchAppStoreReviewsJob.perform_later(channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform(channel)
|
||||
return unless channel.account.feature_enabled?(:channel_app_store)
|
||||
|
||||
synced_until = sync_reviews(channel)
|
||||
return if synced_until.blank?
|
||||
|
||||
channel.update!(last_synced_at: synced_until)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_reviews(channel)
|
||||
failed = false
|
||||
synced_dates = []
|
||||
|
||||
channel.fetch_reviews.each do |review_payload|
|
||||
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
|
||||
synced_dates << payload_synced_at(review_payload)
|
||||
rescue StandardError => e
|
||||
failed = true
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
return if failed
|
||||
|
||||
[channel.last_synced_at, synced_dates.compact.max].compact.max
|
||||
end
|
||||
|
||||
def payload_synced_at(review_payload)
|
||||
[
|
||||
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||
].compact.max
|
||||
end
|
||||
|
||||
def parsed_timestamp(value)
|
||||
Time.zone.parse(value.to_s)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,6 @@ class SendReplyJob < ApplicationJob
|
||||
'Channel::Sms' => ::Sms::SendOnSmsService,
|
||||
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
|
||||
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
|
||||
'Channel::AppStore' => ::AppStore::SendOnAppStoreService,
|
||||
'Channel::Email' => ::Email::SendOnEmailService,
|
||||
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
|
||||
'Channel::Api' => ::Messages::SendEmailNotificationService
|
||||
|
||||
@@ -61,7 +61,6 @@ class Account < ApplicationRecord
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
has_many :agent_bots, dependent: :destroy_async
|
||||
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
|
||||
has_many :app_store_channels, dependent: :destroy_async, class_name: '::Channel::AppStore'
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
class Channel::AppStore < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
self.table_name = 'channel_app_store'
|
||||
|
||||
EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze
|
||||
|
||||
API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze
|
||||
REVIEWS_PAGE_SIZE = 200
|
||||
SYNC_INTERVAL = 1.hour
|
||||
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :issuer_id
|
||||
encrypts :key_id
|
||||
encrypts :private_key
|
||||
end
|
||||
|
||||
validates :app_id, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :issuer_id, :key_id, :private_key, presence: true
|
||||
validate :validate_app_access, on: :create
|
||||
|
||||
before_validation :normalize_auth_fields
|
||||
|
||||
after_create_commit :enqueue_initial_review_fetch
|
||||
|
||||
def name
|
||||
'App Store'
|
||||
end
|
||||
|
||||
def sync_due?
|
||||
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
|
||||
end
|
||||
|
||||
def fetch_reviews
|
||||
app_store_client.fetch_reviews(since: last_synced_at)
|
||||
end
|
||||
|
||||
def reply_to_review(review_id, response_body)
|
||||
response = app_store_client.create_or_update_review_response(review_id, response_body)
|
||||
|
||||
response['id']
|
||||
end
|
||||
|
||||
def app_store_client
|
||||
@app_store_client ||= AppStoreConnect::Client.new(channel: self)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_auth_fields
|
||||
self.app_id = app_id.to_s.strip
|
||||
self.issuer_id = issuer_id.to_s.strip
|
||||
self.key_id = key_id.to_s.strip
|
||||
self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip
|
||||
end
|
||||
|
||||
def validate_app_access
|
||||
app = app_store_client.fetch_app
|
||||
self.app_name = app.dig('attributes', 'name') if app_name.blank?
|
||||
self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank?
|
||||
rescue StandardError => e
|
||||
errors.add(:base, e.message)
|
||||
end
|
||||
|
||||
def enqueue_initial_review_fetch
|
||||
::Inboxes::FetchAppStoreReviewsJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -148,7 +148,7 @@ class Contact < ApplicationRecord
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
data = {
|
||||
additional_attributes: additional_attributes,
|
||||
custom_attributes: custom_attributes,
|
||||
email: email,
|
||||
@@ -160,6 +160,8 @@ class Contact < ApplicationRecord
|
||||
blocked: blocked,
|
||||
type: 'contact'
|
||||
}
|
||||
data[:company_id] = company_id if account.feature_enabled?('companies')
|
||||
data
|
||||
end
|
||||
|
||||
def webhook_data
|
||||
|
||||
@@ -134,10 +134,6 @@ class Inbox < ApplicationRecord
|
||||
channel_type == 'Channel::Tiktok'
|
||||
end
|
||||
|
||||
def app_store?
|
||||
channel_type == 'Channel::AppStore'
|
||||
end
|
||||
|
||||
def web_widget?
|
||||
channel_type == 'Channel::WebWidget'
|
||||
end
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
class AppStore::ReviewBuilder
|
||||
pattr_initialize [:review_payload!, :channel!]
|
||||
|
||||
def perform
|
||||
return if review_id.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact_inbox
|
||||
@contact_inbox.with_lock do
|
||||
build_conversation
|
||||
@conversation.with_lock do
|
||||
upsert_review_message
|
||||
build_response_message if response_body.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= channel.inbox
|
||||
end
|
||||
|
||||
def review
|
||||
@review ||= review_payload['review'] || {}
|
||||
end
|
||||
|
||||
def response
|
||||
@response ||= review_payload['response'] || {}
|
||||
end
|
||||
|
||||
def attributes
|
||||
@attributes ||= review['attributes'] || {}
|
||||
end
|
||||
|
||||
def response_attributes
|
||||
@response_attributes ||= response['attributes'] || {}
|
||||
end
|
||||
|
||||
def review_id
|
||||
review['id']
|
||||
end
|
||||
|
||||
def review_body
|
||||
attributes['body'].to_s.strip
|
||||
end
|
||||
|
||||
def review_title
|
||||
attributes['title'].to_s.strip
|
||||
end
|
||||
|
||||
def rating
|
||||
attributes['rating'].to_i.clamp(0, 5)
|
||||
end
|
||||
|
||||
def created_at
|
||||
Time.zone.parse(attributes['createdDate'].to_s).presence || Time.current
|
||||
rescue StandardError
|
||||
Time.current
|
||||
end
|
||||
|
||||
def response_id
|
||||
response['id']
|
||||
end
|
||||
|
||||
def response_body
|
||||
response_attributes['responseBody'].to_s.strip
|
||||
end
|
||||
|
||||
def response_created_at
|
||||
Time.zone.parse(response_attributes['lastModifiedDate'].to_s).presence || Time.current
|
||||
rescue StandardError
|
||||
Time.current
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: review_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: attributes['reviewerNickname'].presence || 'App Store User',
|
||||
additional_attributes: { source_id: "app_store:#{review_id}" }
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { source: 'app_store', app_id: channel.app_id }
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_review_message
|
||||
message = @conversation.messages.find_by(source_id: review_id)
|
||||
if message
|
||||
message.update!(content: message_content, content_attributes: review_metadata)
|
||||
return
|
||||
end
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @conversation.contact,
|
||||
message_type: :incoming,
|
||||
source_id: review_id,
|
||||
content: message_content,
|
||||
content_attributes: review_metadata,
|
||||
created_at: created_at,
|
||||
updated_at: created_at
|
||||
)
|
||||
end
|
||||
|
||||
def build_response_message
|
||||
return if response_id.blank?
|
||||
|
||||
message = @conversation.messages.find_by(source_id: response_id)
|
||||
if message
|
||||
message.update!(content: response_body, content_attributes: response_metadata, updated_at: response_created_at)
|
||||
return
|
||||
end
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
source_id: response_id,
|
||||
content: response_body,
|
||||
status: :delivered,
|
||||
content_attributes: response_metadata,
|
||||
created_at: response_created_at,
|
||||
updated_at: response_created_at
|
||||
)
|
||||
end
|
||||
|
||||
def message_content
|
||||
[
|
||||
rating_line,
|
||||
review_title.presence,
|
||||
review_body,
|
||||
review_footer
|
||||
].compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
def rating_line
|
||||
stars = ('★' * rating) + ('☆' * (5 - rating))
|
||||
"#{stars} (#{rating}/5)"
|
||||
end
|
||||
|
||||
def review_footer
|
||||
parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank
|
||||
return nil if parts.empty?
|
||||
|
||||
parts.join(' • ')
|
||||
end
|
||||
|
||||
def review_metadata
|
||||
{
|
||||
app_store: {
|
||||
rating: rating,
|
||||
title: review_title,
|
||||
territory: attributes['territory'],
|
||||
reviewer_nickname: attributes['reviewerNickname'],
|
||||
created_date: attributes['createdDate']
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def response_metadata
|
||||
{
|
||||
external_echo: true,
|
||||
app_store: {
|
||||
response_id: response_id,
|
||||
response_state: response_attributes['state'],
|
||||
response_last_modified_date: response_attributes['lastModifiedDate']
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::AppStore
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
validate_feature_enabled!
|
||||
validate_message_support!
|
||||
source_id = channel.reply_to_review(review_id, reply_content)
|
||||
sync_response_message(source_id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
|
||||
def validate_message_support!
|
||||
raise 'Only outgoing text messages are supported for App Store reviews.' unless message.outgoing? && message.text?
|
||||
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
|
||||
end
|
||||
|
||||
def validate_feature_enabled!
|
||||
return if message.account.feature_enabled?(:channel_app_store)
|
||||
|
||||
raise 'App Store Reviews channel is not enabled for this account.'
|
||||
end
|
||||
|
||||
def review_id
|
||||
message.conversation.contact_inbox.source_id
|
||||
end
|
||||
|
||||
def reply_content
|
||||
message.outgoing_content.presence || message.content
|
||||
end
|
||||
|
||||
def existing_response_message(source_id)
|
||||
return if source_id.blank?
|
||||
|
||||
message.conversation.messages.where.not(id: message.id).find_by(source_id: source_id)
|
||||
end
|
||||
|
||||
def sync_response_message(source_id)
|
||||
response_message = existing_response_message(source_id)
|
||||
|
||||
if response_message.present?
|
||||
update_existing_response_message(response_message, source_id)
|
||||
message.destroy!
|
||||
Messages::StatusUpdateService.new(response_message, 'delivered').perform
|
||||
else
|
||||
message.update!(source_id: source_id) if source_id.present?
|
||||
Messages::StatusUpdateService.new(message, 'delivered').perform
|
||||
end
|
||||
end
|
||||
|
||||
def update_existing_response_message(response_message, source_id)
|
||||
content_attributes = (response_message.content_attributes || {}).deep_merge(
|
||||
'external_echo' => true,
|
||||
'app_store' => {
|
||||
'response_id' => source_id
|
||||
}
|
||||
)
|
||||
|
||||
response_message.update!(content: reply_content, content_attributes: content_attributes)
|
||||
end
|
||||
end
|
||||
@@ -1,152 +0,0 @@
|
||||
class AppStoreConnect::Client
|
||||
class Error < StandardError; end
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def fetch_app
|
||||
get("/v1/apps/#{channel.app_id}")['data']
|
||||
end
|
||||
|
||||
def fetch_reviews(since: nil)
|
||||
reviews = []
|
||||
next_url = nil
|
||||
|
||||
loop do
|
||||
payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query)
|
||||
included = Array(payload['included'])
|
||||
review_payloads = Array(payload['data']).map { |review| normalize_review(review, included) }
|
||||
fresh_payloads = fresh_review_payloads(review_payloads, since)
|
||||
reviews.concat(fresh_payloads)
|
||||
|
||||
next_url = payload.dig('links', 'next')
|
||||
break if next_url.blank?
|
||||
end
|
||||
|
||||
reviews
|
||||
end
|
||||
|
||||
def create_review_response(review_id, response_body)
|
||||
create_or_update_review_response(review_id, response_body)
|
||||
end
|
||||
|
||||
def create_or_update_review_response(review_id, response_body)
|
||||
post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reviews_path
|
||||
"/v1/apps/#{channel.app_id}/customerReviews"
|
||||
end
|
||||
|
||||
def reviews_query
|
||||
{
|
||||
include: 'response',
|
||||
limit: Channel::AppStore::REVIEWS_PAGE_SIZE,
|
||||
sort: '-createdDate'
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_review(review, included)
|
||||
response_id = review.dig('relationships', 'response', 'data', 'id')
|
||||
response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id }
|
||||
|
||||
{
|
||||
'review' => review,
|
||||
'response' => response
|
||||
}
|
||||
end
|
||||
|
||||
def fresh_review_payloads(review_payloads, since)
|
||||
return review_payloads if since.blank?
|
||||
|
||||
review_payloads.select { |review_payload| review_updated_after?(review_payload, since) }
|
||||
end
|
||||
|
||||
def review_updated_after?(review_payload, since)
|
||||
review_updated_at = [
|
||||
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||
].compact.max
|
||||
|
||||
return true if review_updated_at.blank?
|
||||
|
||||
review_updated_at >= since
|
||||
end
|
||||
|
||||
def parsed_timestamp(value)
|
||||
Time.zone.parse(value.to_s)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def get(path, query = {})
|
||||
request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query)
|
||||
end
|
||||
|
||||
def get_url(url)
|
||||
request(:get, url)
|
||||
end
|
||||
|
||||
def post(path, body)
|
||||
request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
|
||||
end
|
||||
|
||||
def request(method, url, query: {}, body: nil)
|
||||
response = HTTParty.public_send(
|
||||
method,
|
||||
url,
|
||||
headers: headers,
|
||||
query: query,
|
||||
body: body&.to_json
|
||||
)
|
||||
|
||||
log_rate_limit(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
raise Error, error_message(response)
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'Authorization' => "Bearer #{token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def token
|
||||
AppStoreConnect::TokenService.new(channel: channel).token
|
||||
end
|
||||
|
||||
def review_response_payload(review_id, response_body)
|
||||
{
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: response_body.to_s
|
||||
},
|
||||
relationships: {
|
||||
review: {
|
||||
data: {
|
||||
type: 'customerReviews',
|
||||
id: review_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def log_rate_limit(response)
|
||||
rate_limit = response.headers['x-rate-limit']
|
||||
Rails.logger.debug { "[APP_STORE_CONNECT] rate_limit=#{rate_limit}" } if rate_limit.present?
|
||||
end
|
||||
|
||||
def error_message(response)
|
||||
parsed_response = response.parsed_response
|
||||
errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : []
|
||||
details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ')
|
||||
details = response.body if details.blank?
|
||||
"App Store Connect API failed (#{response.code}): #{details}"
|
||||
end
|
||||
end
|
||||
@@ -1,50 +0,0 @@
|
||||
class AppStoreConnect::TokenService
|
||||
TOKEN_TTL = 19.minutes
|
||||
EXPIRY_BUFFER = 1.minute
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def token
|
||||
cached_token || generate_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cached_token
|
||||
Rails.cache.read(cache_key)
|
||||
end
|
||||
|
||||
def generate_token
|
||||
token = JWT.encode(payload, private_key, 'ES256', headers)
|
||||
Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER)
|
||||
token
|
||||
end
|
||||
|
||||
def payload
|
||||
now = Time.current.to_i
|
||||
{
|
||||
iss: channel.issuer_id,
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL.to_i,
|
||||
aud: 'appstoreconnect-v1'
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
kid: channel.key_id,
|
||||
typ: 'JWT'
|
||||
}
|
||||
end
|
||||
|
||||
def private_key
|
||||
OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n"))
|
||||
end
|
||||
|
||||
def cache_key
|
||||
identifier = channel.id || channel.object_id
|
||||
version = channel.updated_at&.to_i || 'new'
|
||||
|
||||
"app_store_connect_token:#{identifier}:#{version}"
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,8 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
return false if conversation.campaign.present?
|
||||
return false if review_inbox_or_tweet_conversation?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# should not send for outbound messages
|
||||
return false unless message.incoming?
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
@@ -37,15 +38,12 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
def should_send_greeting?
|
||||
return false if conversation.campaign.present?
|
||||
return false if review_inbox_or_tweet_conversation?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
|
||||
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
|
||||
end
|
||||
|
||||
def review_inbox_or_tweet_conversation?
|
||||
conversation.tweet? || inbox.app_store?
|
||||
end
|
||||
|
||||
def email_collect_was_sent?
|
||||
conversation.messages.where(content_type: 'input_email').present?
|
||||
end
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
class UserSessionTrackingService
|
||||
# CFNetwork UAs cannot distinguish iPhone from iPad; both get labelled iPhone here.
|
||||
LEGACY_MOBILE_UAS = [
|
||||
{ match: %r{\Aokhttp/}, platform: 'Android', device: 'Android' },
|
||||
{ match: %r{\AChatwoot/.*CFNetwork.*Darwin}, platform: 'iPhone', device: 'iPhone' }
|
||||
].freeze
|
||||
private_constant :LEGACY_MOBILE_UAS
|
||||
|
||||
def initialize(user:, request:, client_id:)
|
||||
@user = user
|
||||
@request = request
|
||||
@@ -26,7 +33,7 @@ class UserSessionTrackingService
|
||||
def session_attributes
|
||||
browser = Browser.new(@request.user_agent)
|
||||
|
||||
{
|
||||
attrs = {
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent,
|
||||
browser_name: browser.name,
|
||||
@@ -35,5 +42,22 @@ class UserSessionTrackingService
|
||||
platform_name: browser.platform.name,
|
||||
platform_version: browser.platform.version
|
||||
}
|
||||
|
||||
patch_for_legacy_mobile(attrs)
|
||||
end
|
||||
|
||||
def patch_for_legacy_mobile(attrs)
|
||||
return attrs unless attrs[:browser_name] == 'Unknown Browser'
|
||||
|
||||
hit = LEGACY_MOBILE_UAS.find { |m| @request.user_agent.to_s.match?(m[:match]) }
|
||||
return attrs unless hit
|
||||
|
||||
attrs.merge(
|
||||
browser_name: 'Chatwoot Mobile',
|
||||
browser_version: nil,
|
||||
platform_name: hit[:platform],
|
||||
platform_version: nil,
|
||||
device_name: hit[:device]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ json.name resource.name
|
||||
json.phone_number resource.phone_number
|
||||
json.blocked resource.blocked
|
||||
json.identifier resource.identifier
|
||||
json.company_id resource.company_id if Current.account&.feature_enabled?('companies')
|
||||
json.thumbnail resource.avatar_url
|
||||
json.custom_attributes resource.custom_attributes
|
||||
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
|
||||
|
||||
@@ -63,14 +63,6 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||
## Tiktok Attributes
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
|
||||
|
||||
## App Store Attributes
|
||||
if resource.app_store?
|
||||
json.app_id resource.channel.try(:app_id)
|
||||
json.bundle_id resource.channel.try(:bundle_id)
|
||||
json.app_name resource.channel.try(:app_name)
|
||||
json.last_synced_at resource.channel.try(:last_synced_at)
|
||||
end
|
||||
|
||||
## Twilio Attributes
|
||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||
json.phone_number resource.channel.try(:phone_number)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.14.2'
|
||||
version: '4.15.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+4
-4
@@ -108,9 +108,10 @@
|
||||
display_name: Custom Tools
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: channel_app_store
|
||||
display_name: App Store Reviews Channel
|
||||
enabled: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: insert_article_in_reply
|
||||
display_name: Insert Article in Reply
|
||||
enabled: false
|
||||
@@ -199,7 +200,6 @@
|
||||
display_name: Advanced Search
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: saml
|
||||
display_name: SAML
|
||||
enabled: false
|
||||
|
||||
@@ -203,6 +203,15 @@ class Rack::Attack
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent abuse of conversation delete API (per account)
|
||||
throttle('/api/v1/accounts/:account_id/conversations/:id DELETE',
|
||||
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_DELETE', '60').to_i, period: 1.minute) do |req|
|
||||
next unless req.delete?
|
||||
|
||||
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/conversations/(?<id>\d+)/?\z}.match(req.path_without_extensions)
|
||||
match_data[:account_id] if match_data.present?
|
||||
end
|
||||
|
||||
## Prevent Abuse of attachment upload APIs ##
|
||||
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
|
||||
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
|
||||
|
||||
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
|
||||
resources :scenarios
|
||||
end
|
||||
resources :assistant_responses
|
||||
resources :message_reports, only: [:create]
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
|
||||
@@ -26,12 +26,6 @@ trigger_imap_email_inboxes_job:
|
||||
class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 15 minutes to fetch App Store reviews
|
||||
trigger_app_store_review_inboxes_job:
|
||||
cron: '*/15 * * * *'
|
||||
class: 'Inboxes::FetchAppStoreReviewInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed daily at 2230 UTC
|
||||
# which is our lowest traffic time
|
||||
remove_stale_contact_inboxes_job.rb:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
class CreateChannelAppStore < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :channel_app_store do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.string :app_id, null: false
|
||||
t.string :bundle_id
|
||||
t.string :app_name
|
||||
t.string :issuer_id, null: false
|
||||
t.string :key_id, null: false
|
||||
t.text :private_key, null: false
|
||||
t.jsonb :provider_config, null: false, default: {}
|
||||
t.datetime :last_synced_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :channel_app_store, [:account_id, :app_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -1,20 +0,0 @@
|
||||
class RepurposeMessageReplyToFlagForChannelAppStore < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# The message_reply_to flag (deprecated) has been renamed to channel_app_store.
|
||||
# Disable it on any accounts that had message_reply_to enabled so the repurposed
|
||||
# flag starts from plan/default configuration instead of inheriting stale state.
|
||||
Account.feature_channel_app_store.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:channel_app_store)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
# Remove the stale message_reply_to entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
||||
# ConfigLoader only adds new flags; it never removes renamed ones.
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |feature| feature['name'] == 'message_reply_to' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_message_reports do |t|
|
||||
t.references :account, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :message, null: false
|
||||
t.references :user, null: false
|
||||
t.string :report_reason, null: false
|
||||
t.text :description
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
+16
-16
@@ -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_06_11_184600) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -399,6 +399,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "captain_message_reports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "message_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.string "report_reason", null: false
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
|
||||
t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
|
||||
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
|
||||
end
|
||||
|
||||
create_table "captain_scenarios", force: :cascade do |t|
|
||||
t.string "title"
|
||||
t.text "description"
|
||||
@@ -450,21 +465,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_app_store", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "app_id", null: false
|
||||
t.string "bundle_id"
|
||||
t.string "app_name"
|
||||
t.string "issuer_id", null: false
|
||||
t.string "key_id", null: false
|
||||
t.text "private_key", null: false
|
||||
t.jsonb "provider_config", default: {}, null: false
|
||||
t.datetime "last_synced_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "app_id"], name: "index_channel_app_store_on_account_id_and_app_id", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_email", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.string "email", null: false
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_cloud_installation
|
||||
before_action :set_message
|
||||
before_action :authorize_conversation
|
||||
before_action :ensure_captain_message
|
||||
|
||||
def create
|
||||
@message_report = @message.message_reports.create!(
|
||||
user: Current.user,
|
||||
report_reason: permitted_params[:report_reason],
|
||||
description: permitted_params[:description]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_cloud_installation
|
||||
render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = Current.account.messages.find(permitted_params[:message_id])
|
||||
end
|
||||
|
||||
def authorize_conversation
|
||||
authorize @message.conversation, :show?
|
||||
end
|
||||
|
||||
def ensure_captain_message
|
||||
return if @message.sender_type == 'Captain::Assistant'
|
||||
|
||||
render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:message_id, :report_reason, :description)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
module Enterprise::Api::V1::Accounts::ContactsController
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params_with_company_id = super
|
||||
return params_with_company_id unless Current.account.feature_enabled?('companies')
|
||||
return params_with_company_id unless params.key?(:company_id)
|
||||
|
||||
params_with_company_id.merge(company_id: permitted_company_id)
|
||||
end
|
||||
|
||||
def permitted_company_id
|
||||
return nil if params[:company_id].blank?
|
||||
|
||||
Current.account.companies.find(params[:company_id]).id
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,20 @@
|
||||
module Enterprise::Api::V1::AccountsSettings
|
||||
def create
|
||||
super
|
||||
record_marketing_attribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_marketing_attribution
|
||||
return if current_user.present?
|
||||
return if @account.blank?
|
||||
|
||||
Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
super + [{ conversation_required_attributes: [] }]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_message_reports
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# report_reason :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_message_reports_on_account_id (account_id)
|
||||
# index_captain_message_reports_on_conversation_id (conversation_id)
|
||||
# index_captain_message_reports_on_message_id (message_id)
|
||||
# index_captain_message_reports_on_user_id (user_id)
|
||||
#
|
||||
class Captain::MessageReport < ApplicationRecord
|
||||
self.table_name = 'captain_message_reports'
|
||||
|
||||
REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
|
||||
|
||||
belongs_to :account
|
||||
# `Captain::Conversation` exists as a job namespace, so the association would
|
||||
# resolve to that module instead of the top-level model without this override.
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :message
|
||||
belongs_to :user
|
||||
|
||||
validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
|
||||
|
||||
before_validation :ensure_account_and_conversation
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_and_conversation
|
||||
return if message.blank?
|
||||
|
||||
self.account ||= message.account
|
||||
self.conversation ||= message.conversation
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,14 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
||||
end
|
||||
|
||||
def plan_email_limit
|
||||
base_limit = plan_base_email_limit
|
||||
return nil if base_limit.nil?
|
||||
return base_limit if free_plan?
|
||||
|
||||
base_limit * [agent_limits.to_i, 1].max
|
||||
end
|
||||
|
||||
def plan_base_email_limit
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value
|
||||
return nil if config.blank? || plan_name.blank?
|
||||
|
||||
@@ -84,6 +92,11 @@ module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleL
|
||||
nil
|
||||
end
|
||||
|
||||
def free_plan?
|
||||
default_plan = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')&.value&.first
|
||||
default_plan.present? && plan_name&.downcase == default_plan['name']&.downcase
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
zero_limits = { documents: 0, responses: 0 }.with_indifferent_access
|
||||
|
||||
@@ -6,6 +6,7 @@ module Enterprise::Concerns::Contact
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
|
||||
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
|
||||
end
|
||||
|
||||
@@ -33,4 +34,14 @@ module Enterprise::Concerns::Contact
|
||||
def record_company_activity
|
||||
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
|
||||
end
|
||||
|
||||
def sync_company_name_from_company
|
||||
self.additional_attributes ||= {}
|
||||
|
||||
if company_id.present?
|
||||
additional_attributes['company_name'] = company&.name
|
||||
else
|
||||
additional_attributes.delete('company_name')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,5 +3,6 @@ module Enterprise::Concerns::Message
|
||||
|
||||
included do
|
||||
has_one :call, dependent: :nullify
|
||||
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Contacts::CompanyAssociationService
|
||||
if company
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
|
||||
contact.update_column(:company_id, company.id)
|
||||
contact.update_columns(company_id: company.id, additional_attributes: contact_attributes_with_company_name(contact, company))
|
||||
Company.increment_counter(:contacts_count, company.id)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
|
||||
@@ -45,4 +45,8 @@ class Contacts::CompanyAssociationService
|
||||
def derive_company_name(contact, domain)
|
||||
contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
|
||||
end
|
||||
|
||||
def contact_attributes_with_company_name(contact, company)
|
||||
(contact.additional_attributes || {}).merge('company_name' => company.name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
channel_app_store
|
||||
channel_tiktok
|
||||
captain_integration
|
||||
captain_document_auto_sync
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
linear_integration
|
||||
|
||||
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
# List of keys that can be managed through this service
|
||||
# TODO: Add account_notes field in future
|
||||
# This field can be used to store notes about account on Chatwoot cloud
|
||||
VALID_KEYS = %w[manually_managed_features].freeze
|
||||
VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'base64'
|
||||
|
||||
class Internal::Accounts::MarketingAttributionService
|
||||
FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
|
||||
LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
|
||||
FIELD_MAX_LENGTH = 500
|
||||
ALLOWED_FIELDS = %w[
|
||||
utm_source
|
||||
utm_medium
|
||||
utm_campaign
|
||||
utm_term
|
||||
utm_content
|
||||
utm_id
|
||||
gclid
|
||||
gbraid
|
||||
wbraid
|
||||
dclid
|
||||
fbclid
|
||||
msclkid
|
||||
ttclid
|
||||
li_fat_id
|
||||
twclid
|
||||
rdt_cid
|
||||
referrer
|
||||
referrer_path
|
||||
landing_page
|
||||
source
|
||||
source_type
|
||||
captured_at
|
||||
].freeze
|
||||
|
||||
pattr_initialize [:account!, :cookies!]
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
|
||||
last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
|
||||
return unless first_touch || last_touch
|
||||
|
||||
existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
|
||||
internal_attributes_service.set(
|
||||
'marketing_attribution',
|
||||
{
|
||||
'first_touch' => first_touch || existing_attribution['first_touch'],
|
||||
'last_touch' => last_touch || existing_attribution['last_touch'],
|
||||
'captured_from' => 'cookie',
|
||||
'stored_at' => Time.current.iso8601
|
||||
}.compact
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attribution_cookie(cookie_name)
|
||||
return if cookies[cookie_name].blank?
|
||||
|
||||
parse_cookie(cookies[cookie_name].to_s)
|
||||
end
|
||||
|
||||
def parse_cookie(cookie_value)
|
||||
validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
|
||||
rescue JSON::ParserError, ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_payload(payload)
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
|
||||
next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
|
||||
|
||||
[key, value.to_s.first(FIELD_MAX_LENGTH)]
|
||||
end.to_h.presence
|
||||
end
|
||||
|
||||
def internal_attributes_service
|
||||
@internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
json.id @message_report.id
|
||||
json.message_id @message_report.message_id
|
||||
json.conversation_id @message_report.conversation_id
|
||||
json.user_id @message_report.user_id
|
||||
json.report_reason @message_report.report_reason
|
||||
json.description @message_report.description
|
||||
json.created_at @message_report.created_at.to_i
|
||||
@@ -4,5 +4,6 @@
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- captain_document_auto_sync
|
||||
- csat_review_notes
|
||||
- conversation_required_attributes
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
require 'yaml'
|
||||
require 'erb'
|
||||
|
||||
class ActiveStorage::Migrator
|
||||
def self.migrate(from_service_name, to_service_name, should_update_service_name: true)
|
||||
configs = load_storage_config
|
||||
# Check if services are configured correctly
|
||||
if configs[from_service_name.to_s].nil? || configs[to_service_name.to_s].nil?
|
||||
raise "Error: The services '#{from_service_name}' or '#{to_service_name}' are not configured correctly."
|
||||
end
|
||||
|
||||
from_service = configure_service(from_service_name, configs)
|
||||
|
||||
configure_blob_service(from_service)
|
||||
|
||||
migrate_blobs(from_service_name, to_service_name, should_update_service_name: should_update_service_name)
|
||||
end
|
||||
|
||||
def self.load_storage_config
|
||||
yaml_with_env = ERB.new(File.read('config/storage.yml')).result
|
||||
YAML.load(yaml_with_env)
|
||||
end
|
||||
|
||||
def self.configure_blob_service(service)
|
||||
ActiveStorage::Blob.service = service
|
||||
end
|
||||
|
||||
def self.configure_service(service_name, configs = load_storage_config)
|
||||
service_config = configs[service_name.to_s]
|
||||
ActiveStorage::Service.configure(service_name, { service_name.to_sym => service_config })
|
||||
end
|
||||
|
||||
def self.migrate_blobs(from_service_name, to_service_name, should_update_service_name: true)
|
||||
to_service = configure_service(to_service_name)
|
||||
blobs = ActiveStorage::Blob.where(service_name: from_service_name.to_s)
|
||||
Rails.logger.debug { "#{blobs.count} Blobs to migrate from #{from_service_name} to #{to_service_name}" }
|
||||
|
||||
blobs.find_each do |blob|
|
||||
Rails.logger.debug { '.' }
|
||||
|
||||
blob.open do |io|
|
||||
checksum = blob.checksum
|
||||
to_service.upload(blob.key, io, checksum: checksum)
|
||||
end
|
||||
|
||||
blob.update!(service_name: to_service_name.to_s) if should_update_service_name
|
||||
rescue ActiveStorage::FileNotFoundError => e
|
||||
Rails.logger.warn { "Skipping missing blob #{blob.id} (#{blob.key}): #{e.message}" }
|
||||
end
|
||||
Rails.logger.debug { 'Successful migration' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace :storage do
|
||||
desc 'Migrate blobs from one storage service to another'
|
||||
# Example: FROM=local TO=amazon UPDATE_BLOB_SERVICE_NAME=true bundle exec rake storage:migrate
|
||||
task migrate: :environment do
|
||||
from_service = ENV.fetch('FROM', nil)
|
||||
to_service = ENV.fetch('TO', nil)
|
||||
should_update_service_name = ActiveModel::Type::Boolean.new.cast(ENV.fetch('UPDATE_BLOB_SERVICE_NAME', true))
|
||||
|
||||
raise 'Missing FROM or TO argument. Usage: FROM=service_name TO=service_name rake storage:migrate' if from_service.nil? || to_service.nil?
|
||||
|
||||
ActiveStorage::Migrator.migrate(from_service.to_sym, to_service.to_sym, should_update_service_name: should_update_service_name)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user