diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue
index a13a7a81d..36ea149ad 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
index b258dc763..7e2b6f0c4 100644
--- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
+++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
@@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
-import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
+import {
+ isVoiceCallEnabled,
+ getVoiceCallProvider,
+ VOICE_CALL_PROVIDERS,
+} from 'dashboard/helper/inbox';
+import {
+ VOICE_CALL_DIRECTION,
+ VOICE_CALL_OUTBOUND_INIT_STATUS,
+} from 'dashboard/components-next/message/constants';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
+import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
+import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
+ // When set, the WhatsApp call continues in this conversation (matching the
+ // header button) instead of looking up the contact's most recent one.
+ conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -30,6 +43,7 @@ const { t } = useI18n();
const dialogRef = ref(null);
+const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -38,13 +52,22 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
-// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
+// Mirror the conversation-header button: block a new call whenever any provider
+// call is already active or ringing, otherwise starting a WhatsApp call here
+// would leave a still-live Twilio (or other) session with no visible control.
+const isCallButtonDisabled = computed(
+ () =>
+ callsStore.hasActiveCall ||
+ callsStore.hasIncomingCall ||
+ isInitiatingCall.value
+);
+
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
@@ -58,23 +81,96 @@ const navigateToConversation = conversationId => {
}
};
-const startCall = async inboxId => {
- if (isInitiatingCall.value) return;
+const whatsappCallSession = useWhatsappCallSession();
+
+// Find the most recent open conversation for this contact in the picked inbox.
+// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
+// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
+// contacts whose latest WhatsApp conversation falls outside the 20 most recent
+// across all inboxes would be treated as having no conversation.
+const findWhatsappConversationId = async inboxId => {
+ const { data } = await ContactAPI.getConversations(props.contactId, {
+ inboxId,
+ });
+ const conversations = data?.payload || [];
+ const match = [...conversations].sort(
+ (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
+ )[0];
+ return match?.id || null;
+};
+
+const startWhatsappCall = async (inboxId, conversationIdHint) => {
+ // WhatsApp /initiate is conversation-scoped, so we must hand it a
+ // conversation. Use the caller's hint when given (in-conversation flow);
+ // otherwise pick the most recent one in the inbox.
+ const conversationId =
+ conversationIdHint || (await findWhatsappConversationId(inboxId));
+ if (!conversationId) {
+ useAlert(t('CONTACT_PANEL.CALL_FAILED'));
+ return;
+ }
+
+ const response =
+ await whatsappCallSession.initiateOutboundCall(conversationId);
+ // The composable returns { status: 'locked' } when an init is already in
+ // flight or a call is already active; treat that as a soft no-op rather than
+ // claiming success.
+ if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
+ if (!response?.id) {
+ // Permission template path returns no call id. Mirror the header button and
+ // surface whether the request was just sent or is already pending instead of
+ // claiming the call started. The permission message lands in the
+ // conversation, so still navigate there.
+ const messageKey =
+ response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
+ ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
+ : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
+ useAlert(t(messageKey));
+ navigateToConversation(conversationId);
+ return;
+ }
+
+ // Stay non-active until the connect cable event arrives — flipping to active
+ // here would start the duration timer before the contact picks up.
+ callsStore.addCall({
+ callSid: response.call_id,
+ callId: response.id,
+ conversationId,
+ inboxId,
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
+ provider: VOICE_CALL_PROVIDERS.WHATSAPP,
+ });
+
+ useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
+ navigateToConversation(conversationId);
+};
+
+const startCall = async (inboxId, conversationIdHint = null) => {
+ if (isCallButtonDisabled.value) return;
+
+ const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
+ if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
+ try {
+ await startWhatsappCall(inboxId, conversationIdHint);
+ } catch (error) {
+ useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
+ }
+ return;
+ }
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
+ conversationId: conversationIdHint,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
- // Add call to store immediately so widget shows
- const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
- callDirection: 'outbound',
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -86,6 +182,22 @@ const startCall = async inboxId => {
};
const onClick = async () => {
+ // In conversation context, only stay in this conversation if its inbox is
+ // itself voice-capable (works the same for Twilio and WhatsApp). For
+ // non-voice channels (email, web, …) fall back to the picker so the call
+ // goes out via a voice inbox.
+ if (props.conversationId) {
+ const conversation = store.getters.getConversationById(
+ props.conversationId
+ );
+ const conversationInbox = (inboxesList.value || []).find(
+ i => i.id === conversation?.inbox_id
+ );
+ if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
+ await startCall(conversationInbox.id, props.conversationId);
+ return;
+ }
+ }
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -106,7 +218,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
- :disabled="isInitiatingCall"
+ :disabled="isCallButtonDisabled"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
index ba8090e02..d3342e18a 100644
--- a/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
+++ b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
- @keyup.enter="handleInputUpdate"
+ @enter="handleInputUpdate"
/>
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index d18be0caa..7f10190e6 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
-import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReplyToMessage from './ReplyToMessage.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
@@ -144,8 +143,6 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
- accountId: 'getCurrentAccountId',
- isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -182,6 +179,21 @@ export default {
}
return true;
},
+ hasMeaningfulEditorContent() {
+ const body = this.message || '';
+ // Only strip the signature when it's actually being auto-appended.
+ // If the toggle is off, the agent's text might happen to match their
+ // saved signature and we'd incorrectly treat it as empty.
+ const shouldStripSignature =
+ !this.isPrivate && this.sendWithSignature && !!this.messageSignature;
+ if (!shouldStripSignature) return !!body.trim();
+ const stripped = removeSignature(
+ body,
+ this.messageSignature,
+ getEffectiveChannelType(this.channelType, this.inbox?.medium || '')
+ );
+ return !!stripped.trim();
+ },
isReplyRestricted() {
return (
!this.currentChat?.can_reply &&
@@ -273,6 +285,10 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
+ const { image_send: imageSend } =
+ this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
+ const tiktokAttachmentSupported = imageSend ?? true;
+
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -283,7 +299,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
- this.isATiktokChannel
+ (this.isATiktokChannel && tiktokAttachmentSupported)
);
},
replyButtonLabel() {
@@ -380,14 +396,8 @@ export default {
const { slug = '' } = portal;
return slug;
},
- isQuotedEmailReplyEnabled() {
- return this.isFeatureEnabledonAccount(
- this.accountId,
- FEATURE_FLAGS.QUOTED_EMAIL_REPLY
- );
- },
quotedReplyPreference() {
- if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
+ if (!this.isAnEmailChannel) {
return false;
}
@@ -412,11 +422,7 @@ export default {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
- return (
- this.isAnEmailChannel &&
- !this.isOnPrivateNote &&
- this.isQuotedEmailReplyEnabled
- );
+ return this.isAnEmailChannel && !this.isOnPrivateNote;
},
shouldShowQuotedPreview() {
return (
@@ -573,7 +579,6 @@ export default {
},
shouldIncludeQuotedEmail() {
return (
- this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
@@ -706,6 +711,7 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -1025,6 +1031,8 @@ export default {
});
},
attachFile({ blob, file }) {
+ if (!this.showFileUpload && !this.isOnPrivateNote) return;
+
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -1238,6 +1246,7 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
+ :has-content="hasMeaningfulEditorContent"
@set-reply-mode="setReplyMode"
@toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor"
diff --git a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
index aa17dabf9..6b356f4d3 100644
--- a/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/components/GalleryView.vue
@@ -22,6 +22,10 @@ const props = defineProps({
type: Array,
required: true,
},
+ autoPlay: {
+ type: Boolean,
+ default: false,
+ },
});
const emit = defineEmits(['close']);
@@ -309,6 +313,7 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
+ :autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -317,6 +322,7 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
+ :autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/AgentSelector.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/AgentSelector.vue
deleted file mode 100644
index 1a7129e62..000000000
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/AgentSelector.vue
+++ /dev/null
@@ -1,254 +0,0 @@
-
-
-
-
-
-
-
-
-
-
{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}
-
-
-
- -
-
-
-
-
-
- -
-
-
-
- {{ agent.name }}
-
-
-
-
-
-
- {{
- $t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
- conversationCount,
- conversationLabel,
- })
- }}
-
- {{ selectedAgent.name }}
-
- ?
-
-
- {{
- $t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
- conversationCount,
- conversationLabel,
- })
- }}
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue
new file mode 100644
index 000000000..d776118de
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkAgentActions.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.conversationCount }}
+
+
+
+
+ {{ selectedAgent.name }}
+
+
+
+
+
+
+ {{ props.conversationCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
new file mode 100644
index 000000000..e46f45da5
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue
@@ -0,0 +1,199 @@
+
+
+
+
+
+
+ toggleLabelSelection(item.value)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkTeamActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkTeamActions.vue
new file mode 100644
index 000000000..3e7bc49ec
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkTeamActions.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.conversationCount }}
+
+
+
+
+ {{ selectedTeam.name }}
+
+
+
+
+
+
+ {{ props.conversationCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkUpdateActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkUpdateActions.vue
new file mode 100644
index 000000000..042a304df
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkUpdateActions.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
index 233b3a300..eef70cf02 100644
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue
@@ -1,7 +1,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
-
-
+
-
-
-
+
+ {{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
+
+
+
+
+
+
+
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue
deleted file mode 100644
index f76cdb190..000000000
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue
+++ /dev/null
@@ -1,141 +0,0 @@
-
-
-
-
-
-
- {{
- t('BULK_ACTION.LABELS.ASSIGN_LABELS')
- }}
-
-
-
-
-
-
- {{
- t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
- }}
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/TeamActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/TeamActions.vue
deleted file mode 100644
index 2bb6bd893..000000000
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/TeamActions.vue
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/UpdateActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/UpdateActions.vue
deleted file mode 100644
index e3280c5af..000000000
--- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/UpdateActions.vue
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
-
-
-
- {{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/SearchableDropdown.vue b/app/javascript/dashboard/components/widgets/conversation/linear/SearchableDropdown.vue
index bce5f720a..32e180a97 100644
--- a/app/javascript/dashboard/components/widgets/conversation/linear/SearchableDropdown.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/linear/SearchableDropdown.vue
@@ -1,5 +1,5 @@
@@ -170,27 +328,43 @@ onMounted(() => {
:current-page="documentsMeta.page"
:show-pagination-footer="!isFetching && !!documents.length"
:is-fetching="isFetching"
- :is-empty="!documents.length"
- :show-know-more="false"
+ :is-empty="!documents.length && !hasActiveDocumentFilters"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
-
-
-
+
+
-
+
+
+
+
+
+
+
{
-
+
+
+ {{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_TITLE') }}
+
+
+ {{ $t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FILTERED_SUBTITLE') }}
+
+
+
+
{
v-if="showCreateDialog"
ref="createDocumentDialog"
:assistant-id="selectedAssistantId"
+ @create-success="onCreateSuccess"
@close="handleCreateDialogClose"
/>
{
type="Documents"
@delete-success="onDeleteSuccess"
/>
-
diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue
index 661b6ced7..9900db288 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Pending.vue
@@ -297,7 +297,7 @@ onMounted(() => {
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
-
+
companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
+const createCompanyDialogRef = ref(null);
const pageNumber = computed(() => Number(route.query?.page) || 1);
const parseSortSettings = (sortString = '') => {
@@ -51,6 +54,7 @@ const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const isFetchingList = computed(() => uiFlags.value.fetchingList);
+const isCreatingCompany = computed(() => uiFlags.value.creatingItem);
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
@@ -111,6 +115,31 @@ const onPageChange = page => {
fetchCompanies(page, searchValue.value, sortParam.value);
};
+const showCompany = companyId => {
+ router.push({
+ name: 'companies_dashboard_show',
+ params: {
+ accountId: route.params.accountId,
+ companyId,
+ },
+ });
+};
+
+const openCreateCompanyDialog = () => {
+ createCompanyDialogRef.value?.dialogRef.open();
+};
+
+const createCompany = async company => {
+ try {
+ const newCompany = await companiesStore.create(company);
+ createCompanyDialogRef.value?.onSuccess();
+ useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
+ showCompany(newCompany.id);
+ } catch {
+ useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
+ }
+};
+
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -123,6 +152,11 @@ const handleSort = async ({ sort, order }) => {
onMounted(() => {
searchValue.value = searchQuery.value;
+
+ if (!route.query.sort && sortParam.value !== DEFAULT_SORT_FIELD) {
+ updateURLParams(pageNumber.value, searchQuery.value, sortParam.value);
+ }
+
fetchCompanies();
});
@@ -140,6 +174,7 @@ onMounted(() => {
@update:current-page="onPageChange"
@update:sort="handleSort"
@search="onSearch"
+ @create="openCreateCompanyDialog"
>
{{
@@ -162,10 +197,15 @@ onMounted(() => {
:name="company.name"
:domain="company.domain"
:contacts-count="company.contactsCount || 0"
- :description="company.description"
:avatar-url="company.avatarUrl"
- :updated-at="company.updatedAt"
+ :last-activity-at="company.lastActivityAt"
+ @show-company="showCompany"
/>
+
diff --git a/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue b/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue
new file mode 100644
index 000000000..86627f952
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/companies/pages/CompanyDetailView.vue
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.LOADING') }}
+
+
+
+
+ {{ t('COMPANIES.DETAIL.EMPTY_STATE.TITLE') }}
+
+
+ {{ t('COMPANIES.DETAIL.EMPTY_STATE.SUBTITLE') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('COMPANIES.DETAIL.DELETE.SECTION_TITLE') }}
+
+
+ {{ t('COMPANIES.DETAIL.DELETE.SECTION_DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (selectedCandidate = contact)"
+ @remove-contact="handleRemoveContact"
+ @update:current-page="loadCompanyContactsPage"
+ />
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/companies/routes.js b/app/javascript/dashboard/routes/dashboard/companies/routes.js
index 69cbba762..42ed6bd18 100644
--- a/app/javascript/dashboard/routes/dashboard/companies/routes.js
+++ b/app/javascript/dashboard/routes/dashboard/companies/routes.js
@@ -1,5 +1,6 @@
import { frontendURL } from '../../../helper/URLHelper';
import CompaniesIndex from './pages/CompaniesIndex.vue';
+import CompanyDetailView from './pages/CompanyDetailView.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
@@ -23,4 +24,17 @@ export const routes = [
},
],
},
+ {
+ path: frontendURL('accounts/:accountId/companies/:companyId'),
+ component: CompanyDetailView,
+ meta: commonMeta,
+ children: [
+ {
+ path: '',
+ name: 'companies_dashboard_show',
+ component: CompanyDetailView,
+ meta: commonMeta,
+ },
+ ],
+ },
];
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue
index d19f84d99..810f4412e 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsBulkActionBar.vue
@@ -1,11 +1,10 @@
@@ -102,60 +90,44 @@ const handleAssignLabels = labels => {
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
-
+
-
-
-
-
-
-
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue
index b072fa67d..951416f0f 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue
@@ -351,6 +351,28 @@ const assignLabels = async labels => {
}
};
+const removeLabels = async labels => {
+ if (!labels.length || !selectedContactIds.value.length) {
+ return;
+ }
+
+ isBulkActionLoading.value = true;
+ try {
+ await BulkActionsAPI.create({
+ type: 'Contact',
+ ids: selectedContactIds.value,
+ labels: { remove: labels },
+ });
+ useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_SUCCESS'));
+ clearSelection();
+ await fetchContactsBasedOnContext(pageNumber.value);
+ } catch (error) {
+ useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_FAILED'));
+ } finally {
+ isBulkActionLoading.value = false;
+ }
+};
+
const deleteContacts = async () => {
if (!selectedContactIds.value.length) {
return;
@@ -514,6 +536,7 @@ onMounted(async () => {
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
+ @remove-labels="removeLabels"
@delete-selected="openBulkDeleteDialog"
/>
{
+
+
toggleSidebarUIState('is_shared_files_open', value)
+ "
+ >
+
+
+
@@ -304,9 +317,7 @@ onMounted(() => {
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
new file mode 100644
index 000000000..272407a53
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/conversation/SharedFiles.vue
@@ -0,0 +1,421 @@
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
+
+ {{ mediaAttachments.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayDuration(attachment) }}
+
+
+
+ {{ displayTime(attachment) }}
+
+
+
+
+
+
+
+
+
+ {{
+ t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
+ count: mediaOverflow,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
+
+ {{ fileAttachments.length }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
index 41c5854e0..a27b308b0 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
@@ -56,7 +56,10 @@ export default {
};
},
computed: {
- ...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
+ ...mapGetters({
+ uiFlags: 'contacts/getUIFlags',
+ currentChat: 'getSelectedChat',
+ }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
@@ -305,11 +308,12 @@ export default {
categories.value[0]?.id || null);
+const categoryId = computed(() => {
+ const { categorySlug } = route.params;
+ if (categorySlug) {
+ const matched = categories.value?.find(c => c.slug === categorySlug);
+ if (matched) return matched.id;
+ }
+ return categories.value[0]?.id || null;
+});
+
+const isCategoryArticles = computed(
+ () => route.name === 'portals_categories_articles_new'
+);
const article = ref({});
const isUpdating = ref(false);
@@ -44,23 +55,34 @@ const createNewArticle = async ({ title, content }) => {
isUpdating.value = true;
try {
const { locale } = route.params;
+ const resolvedCategoryId = selectedCategoryId.value || categoryId.value;
const articleId = await store.dispatch('articles/create', {
portalSlug,
content: article.value.content,
title: article.value.title,
locale: locale,
authorId: selectedAuthorId.value || currentUserId.value,
- categoryId: selectedCategoryId.value || categoryId.value,
+ categoryId: resolvedCategoryId,
});
useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale });
+ const resolvedSlug = categories.value?.find(
+ c => c.id === resolvedCategoryId
+ )?.slug;
+ const startedFromCategorySlug = route.params.categorySlug;
+
router.replace({
- name: 'portals_articles_edit',
+ name: isCategoryArticles.value
+ ? 'portals_categories_articles_edit'
+ : 'portals_articles_edit',
params: {
articleSlug: articleId,
portalSlug,
locale,
+ ...(startedFromCategorySlug
+ ? { categorySlug: resolvedSlug || startedFromCategorySlug }
+ : {}),
},
});
} catch (error) {
@@ -74,10 +96,17 @@ const createNewArticle = async ({ title, content }) => {
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
- router.push({
- name: 'portals_articles_index',
- params: { tab, categorySlug, locale },
- });
+ if (isCategoryArticles.value) {
+ router.push({
+ name: 'portals_categories_articles_index',
+ params: { categorySlug, locale },
+ });
+ } else {
+ router.push({
+ name: 'portals_articles_index',
+ params: { tab, categorySlug, locale },
+ });
+ }
};
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
index 66ac0d57f..592672462 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue
@@ -134,7 +134,7 @@ const populateFormFields = () => {
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
- website.value = account?.domain || brandInfo?.domain || '';
+ website.value = attrs.website || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
@@ -216,7 +216,7 @@ const handleSubmit = async () => {
await updateAccount({
name: accountName.value,
locale: locale.value,
- domain: website.value,
+ website: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
index 2aa2f28cd..61582fd32 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/Index.vue
@@ -32,6 +32,7 @@ const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
const selectedAttribute = ref({});
+const attributeModels = ['conversation_attribute', 'contact_attribute'];
const openAddPopup = () => {
toggleAddPopup(true);
@@ -69,8 +70,8 @@ onMounted(() => {
store.dispatch('attributes/get');
});
-const attributeModel = computed(() =>
- selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
+const attributeModel = computed(
+ () => attributeModels[selectedTabIndex.value] || 'conversation_attribute'
);
const attributes = computed(() =>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
index 56caa2558..7fd23ebb1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue
@@ -143,17 +143,15 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
index d2c906511..7a570a300 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue
@@ -147,17 +147,15 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue
index 5c1810b63..1bc1ae392 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/canned/Index.vue
@@ -4,7 +4,7 @@ import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
-import { computed, onMounted, ref, defineOptions } from 'vue';
+import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
diff --git a/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue b/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue
index 90636dbde..4a3387fa2 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/customRoles/component/CustomRolePaywall.vue
@@ -74,23 +74,25 @@ const tableHeaders = computed(() => {
-
-
-
-
- {{ thHeader }}
-
- |
-
-
+
+
+
+
+ |
+
+ {{ thHeader }}
+
+ |
+
+
+
+
+
+
{
icon: 'i-woot-voice',
});
+ channels.push({
+ key: 'whatsapp_call',
+ title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.TITLE'),
+ description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.DESCRIPTION'),
+ icon: 'i-woot-whatsapp',
+ });
+
return channels;
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue
index 7325793db..ab25ed1af 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ImapSettings.vue
@@ -5,11 +5,13 @@ import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFie
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
+import SingleSelectDropdown from './components/SingleSelectDropdown.vue';
export default {
components: {
SettingsFieldSection,
NextButton,
+ SingleSelectDropdown,
},
props: {
inbox: {
@@ -28,6 +30,12 @@ export default {
login: '',
password: '',
isSSLEnabled: true,
+ authMechanism: 'plain',
+ authMechanisms: [
+ { key: 1, value: 'plain' },
+ { key: 2, value: 'login' },
+ { key: 3, value: 'cram-md5' },
+ ],
};
},
validations: {
@@ -56,6 +64,7 @@ export default {
imap_login,
imap_password,
imap_enable_ssl,
+ imap_authentication,
} = this.inbox;
this.isIMAPEnabled = imap_enabled;
this.address = imap_address;
@@ -63,6 +72,7 @@ export default {
this.login = imap_login;
this.password = imap_password;
this.isSSLEnabled = imap_enable_ssl;
+ this.authMechanism = imap_authentication || 'plain';
},
async updateInbox() {
try {
@@ -77,6 +87,7 @@ export default {
imap_login: this.login,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
+ imap_authentication: this.authMechanism,
},
};
@@ -90,6 +101,9 @@ export default {
useAlert(error.message);
}
},
+ handleAuthMechanismChange(mode) {
+ this.authMechanism = mode;
+ },
},
};
@@ -155,6 +169,13 @@ export default {
/>
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
+
{
.message-editor {
@apply px-3;
- ::v-deep {
- .ProseMirror-menubar {
- @apply rounded-tl-[4px];
- }
+ :deep(.ProseMirror-menubar) {
+ @apply rounded-tl-[4px];
}
}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index a26eb0e18..62cd2b7bc 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -22,6 +22,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
+import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -48,6 +49,7 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
+ WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -249,6 +251,23 @@ export default {
];
}
+ if (
+ this.isAWhatsAppCloudChannel &&
+ this.isEmbeddedSignupWhatsApp &&
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.CHANNEL_VOICE
+ )
+ ) {
+ visibleToAllChannelTabs = [
+ ...visibleToAllChannelTabs,
+ {
+ key: 'calls-configuration',
+ name: this.$t('INBOX_MGMT.TABS.CALLS'),
+ },
+ ];
+ }
+
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -1262,6 +1281,12 @@ export default {
>
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
new file mode 100644
index 000000000..c27cd7d1f
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index cf5c1310e..668e0709a 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
+import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
@@ -16,6 +17,13 @@ import {
isValidBusinessData,
} from './whatsapp/utils';
+const props = defineProps({
+ enableCallingOnComplete: {
+ type: Boolean,
+ default: false,
+ },
+});
+
const store = useStore();
const router = useRouter();
const { t } = useI18n();
@@ -65,11 +73,27 @@ const handleSignupCancellation = () => {
isAuthenticating.value = false;
};
-const handleSignupSuccess = inboxData => {
- isProcessing.value = false;
- isAuthenticating.value = false;
+const enableCallingForInbox = async inboxId => {
+ try {
+ await InboxesAPI.enableWhatsappCalling(inboxId);
+ } catch (_) {
+ useAlert(
+ t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED')
+ );
+ }
+};
+const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
+ if (props.enableCallingOnComplete) {
+ isProcessing.value = true;
+ processingMessage.value = t(
+ 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
+ );
+ await enableCallingForInbox(inboxData.id);
+ }
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => {
},
});
} else {
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
index a5c16fa57..e49d98423 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/Google.vue
@@ -1,6 +1,5 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
index 1a4d36fee..893a08532 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
@@ -63,6 +63,13 @@ export default {
isIntegrationDialogflow() {
return this.integration.id === 'dialogflow';
},
+ submitButtonLabel() {
+ if (this.integration.id === 'openai' && this.uiFlags.isCreatingHook) {
+ return this.$t('INTEGRATION_APPS.ADD.FORM.VALIDATING_OPENAI');
+ }
+
+ return this.$t('INTEGRATION_APPS.ADD.FORM.SUBMIT');
+ },
},
methods: {
onClose() {
@@ -154,7 +161,7 @@ export default {
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
index 405656542..14fec4996 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/SingleIntegrationHooks.vue
@@ -1,5 +1,4 @@
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
+ shallowMount(MacroProperties, {
+ props: {
+ macroName: 'Close conversation',
+ macroVisibility: 'personal',
+ ...props,
+ },
+ global: {
+ provide: {
+ v$: {
+ macro: {
+ name: {
+ $error: false,
+ },
+ },
+ },
+ },
+ stubs: {
+ WootInput: true,
+ NextButton: true,
+ Icon: true,
+ },
+ },
+ });
+
+describe('MacroProperties.vue', () => {
+ it('allows administrators to select public visibility', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: true });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeUndefined();
+ expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
+ });
+
+ it('disables public visibility for agents with helper copy', async () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+ const publicButton = wrapper.findAll('button')[0];
+
+ await publicButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can create public macros.'
+ );
+ });
+
+ it('keeps existing public macros visibly selected when public is disabled', () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ });
+
+ expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
+ });
+
+ it('shows existing public macros as read-only for agents', async () => {
+ const wrapper = mountComponent({
+ canManagePublicMacros: false,
+ macroVisibility: 'global',
+ readOnly: true,
+ });
+ const [publicButton, privateButton] = wrapper.findAll('button');
+
+ await privateButton.trigger('click');
+
+ expect(publicButton.attributes('disabled')).toBeDefined();
+ expect(privateButton.attributes('disabled')).toBeDefined();
+ expect(wrapper.emitted('update:visibility')).toBeUndefined();
+ expect(wrapper.text()).toContain(
+ 'Only administrators can edit public macros.'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
new file mode 100644
index 000000000..268a54240
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/specs/MacrosTableRow.spec.js
@@ -0,0 +1,63 @@
+import { shallowMount } from '@vue/test-utils';
+import MacrosTableRow from '../MacrosTableRow.vue';
+
+const macro = visibility => ({
+ id: 1,
+ name: 'Close conversation',
+ visibility,
+ created_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+ updated_by: {
+ available_name: 'Maya Chen',
+ email: 'maya.chen@example.com',
+ },
+});
+
+const mountComponent = props =>
+ shallowMount(MacrosTableRow, {
+ props: {
+ macro: macro('global'),
+ canManagePublicMacros: true,
+ ...props,
+ },
+ global: {
+ stubs: {
+ Avatar: true,
+ BaseTableRow: {
+ template: '
',
+ },
+ BaseTableCell: {
+ template: '
',
+ },
+ Button: true,
+ RouterLink: {
+ template: '',
+ },
+ },
+ },
+ });
+
+describe('MacrosTableRow.vue', () => {
+ it('shows actions for public macros when public macros can be managed', () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+
+ it('keeps public macros viewable without delete actions when public macros cannot be managed', () => {
+ const wrapper = mountComponent({ canManagePublicMacros: false });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(1);
+ });
+
+ it('keeps actions available for personal macros when public macros cannot be managed', () => {
+ const wrapper = mountComponent({
+ macro: macro('personal'),
+ canManagePublicMacros: false,
+ });
+
+ expect(wrapper.findAllComponents({ name: 'Button' })).toHaveLength(2);
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/HotKeyCard.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/HotKeyCard.vue
deleted file mode 100644
index aa5435d2b..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/HotKeyCard.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
-
-
- {{ description }}
-
-
-
-
-
![]()
-
![]()
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
index ed674e3bc..75eb8a2f8 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue
@@ -13,7 +13,6 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
-import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
@@ -22,6 +21,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
+import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -36,7 +36,7 @@ export default {
UserProfilePicture,
Policy,
UserBasicDetails,
- HotKeyCard,
+ RadioCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
@@ -268,26 +268,27 @@ export default {
-
-
-
+
![]()
+
{
:menu-items="inboxMenuItems"
show-search
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
- class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
+ class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96"
@action="handleInboxAction($event)"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue
index 9e9021e93..20a58a4a7 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/heatmaps/HeatmapDateRangeSelector.vue
@@ -1,5 +1,5 @@
+
+ {{ kbd }}
+
diff --git a/app/javascript/portal/components/SearchSuggestions.vue b/app/javascript/portal/components/SearchSuggestions.vue
index 282275547..71853848c 100644
--- a/app/javascript/portal/components/SearchSuggestions.vue
+++ b/app/javascript/portal/components/SearchSuggestions.vue
@@ -29,7 +29,7 @@ export default {
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
- const { highlightContent } = useMessageFormatter();
+ const { highlightContent, getPlainText } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
@@ -37,9 +37,7 @@ export default {
};
const isSearchItemActive = index => {
- return index === selectedIndex.value
- ? 'bg-slate-25 dark:bg-slate-800'
- : 'bg-white dark:bg-slate-900';
+ return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
};
useKeyboardNavigableList({
@@ -53,6 +51,7 @@ export default {
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
+ getPlainText,
};
},
@@ -70,7 +69,7 @@ export default {
return this.highlightContent(
content,
this.searchTerm,
- 'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
+ 'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
);
},
},
@@ -80,46 +79,46 @@ export default {
-
+
{{ loadingPlaceholder }}
-
+
{{ emptyPlaceholder }}
diff --git a/app/javascript/portal/components/SidebarThemeToggle.vue b/app/javascript/portal/components/SidebarThemeToggle.vue
new file mode 100644
index 000000000..0cfd5fc87
--- /dev/null
+++ b/app/javascript/portal/components/SidebarThemeToggle.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ opt.label }}
+
+
+
+
+
+
diff --git a/app/javascript/portal/components/TableOfContents.vue b/app/javascript/portal/components/TableOfContents.vue
index 3cb140018..fb2aa21b7 100644
--- a/app/javascript/portal/components/TableOfContents.vue
+++ b/app/javascript/portal/components/TableOfContents.vue
@@ -79,13 +79,13 @@ export default {
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
- return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
+ return 'border-n-portal transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
- return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
+ return 'text-n-portal transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
@@ -113,7 +113,7 @@ export default {
{{ element.title }}
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 18a54c58e..d654f9054 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -7,6 +7,7 @@ import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
+import SidebarThemeToggle from './components/SidebarThemeToggle.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -78,18 +79,23 @@ export const InitializationHelpers = {
},
initializeSearch: () => {
- const isSearchContainerAvailable = document.querySelector('#search-wrap');
- if (isSearchContainerAvailable) {
+ ['#search-wrap', '#search-wrap-hero'].forEach(selector => {
+ const mountPoint = document.querySelector(selector);
+ if (!mountPoint) return;
+ const size = mountPoint.dataset.size || 'default';
+ const showKbd = !!mountPoint.dataset.kbd;
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
- template: '',
+ data() {
+ return { size, showKbd };
+ },
+ template: '',
});
-
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
- app.mount('#search-wrap');
- }
+ app.mount(selector);
+ });
},
initializeTableOfContents: () => {
@@ -109,6 +115,31 @@ export const InitializationHelpers = {
}
},
+ initializeSidebarThemeToggle: () => {
+ const mountPoint = document.querySelector('#sidebar-theme-toggle');
+ if (mountPoint) {
+ // eslint-disable-next-line vue/one-component-per-file
+ const app = createApp({
+ components: { SidebarThemeToggle },
+ template: '',
+ });
+ app.directive('on-clickaway', onClickaway);
+ app.mount('#sidebar-theme-toggle');
+ }
+ },
+
+ initializeDetailsClickAway: () => {
+ document.addEventListener('click', event => {
+ document
+ .querySelectorAll('details[data-close-on-clickaway][open]')
+ .forEach(details => {
+ if (!details.contains(event.target)) {
+ details.removeAttribute('open');
+ }
+ });
+ });
+ },
+
appendPlainParamToURLs: () => {
[...document.getElementsByTagName('a')].forEach(aTagElement => {
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
@@ -143,6 +174,8 @@ export const InitializationHelpers = {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
+ InitializationHelpers.initializeSidebarThemeToggle();
+ InitializationHelpers.initializeDetailsClickAway();
}
},
diff --git a/app/javascript/portal/specs/SearchSuggestions.spec.js b/app/javascript/portal/specs/SearchSuggestions.spec.js
index 6352c1725..96475dc0b 100644
--- a/app/javascript/portal/specs/SearchSuggestions.spec.js
+++ b/app/javascript/portal/specs/SearchSuggestions.spec.js
@@ -9,6 +9,7 @@ vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
+ getPlainText: content => content,
}),
}));
diff --git a/app/javascript/shared/components/CustomerSatisfaction.vue b/app/javascript/shared/components/CustomerSatisfaction.vue
index 10792aca2..de0647069 100644
--- a/app/javascript/shared/components/CustomerSatisfaction.vue
+++ b/app/javascript/shared/components/CustomerSatisfaction.vue
@@ -4,6 +4,7 @@ import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import StarRating from 'shared/components/StarRating.vue';
+import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
@@ -30,6 +31,10 @@ export default {
default: '',
},
},
+ setup() {
+ const { formatMessage } = useMessageFormatter();
+ return { formatMessage };
+ },
data() {
return {
email: '',
@@ -49,7 +54,9 @@ export default {
?.feedback_message;
},
isButtonDisabled() {
- return !(this.selectedRating && this.feedback);
+ if (!(this.selectedRating && this.feedback)) return true;
+ if (this.isUpdating) return true;
+ return false;
},
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -59,6 +66,9 @@ export default {
? this.$t('CSAT.SUBMITTED_TITLE')
: this.message || this.$t('CSAT.TITLE');
},
+ formattedTitle() {
+ return this.formatMessage(this.title, false);
+ },
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
},
@@ -79,14 +89,16 @@ export default {
methods: {
buttonClass(rating) {
+ const isLocked = this.isFeedbackSubmitted || this.isUpdating;
return [
{ selected: rating.value === this.selectedRating },
- { disabled: this.isRatingSubmitted },
- { hover: this.isRatingSubmitted },
+ { disabled: isLocked },
+ { hover: isLocked },
'emoji-button',
];
},
async onSubmit() {
+ if (this.isUpdating) return;
this.isUpdating = true;
try {
await this.$store.dispatch('message/update', {
@@ -106,10 +118,12 @@ export default {
},
selectRating(rating) {
+ if (this.isFeedbackSubmitted || this.isUpdating) return;
this.selectedRating = rating.value;
this.onSubmit();
},
selectStarRating(value) {
+ if (this.isFeedbackSubmitted || this.isUpdating) return;
this.selectedRating = value;
this.onSubmit();
},
@@ -122,9 +136,10 @@ export default {
class="customer-satisfaction w-full bg-n-background dark:bg-n-solid-3 shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] rounded-lg inline-block leading-[1.5] mt-1 border-t-2 border-t-n-brand border-solid"
:style="{ borderColor: widgetColor }"
>
-
- {{ title }}
-
+
diff --git a/app/views/public/api/v1/portals/articles/show.html+documentation.erb b/app/views/public/api/v1/portals/articles/show.html+documentation.erb
new file mode 100644
index 000000000..76aeb804e
--- /dev/null
+++ b/app/views/public/api/v1/portals/articles/show.html+documentation.erb
@@ -0,0 +1,44 @@
+<% content_for :head do %>
+ <%= render 'public/api/v1/portals/documentation_layout/articles/meta_head',
+ article: @article, portal: @portal, og_image_url: @og_image_url %>
+<% end %>
+
+
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
+ portal: @portal, locale: @locale, category: @article.category, article: @article %>
+ <%= render 'public/api/v1/portals/documentation_layout/articles/header',
+ portal: @portal, article: @article %>
+
+
+ <%= @parsed_content %>
+
+
+ <% if @article.category %>
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/public/api/v1/portals/categories/show.html+documentation.erb b/app/views/public/api/v1/portals/categories/show.html+documentation.erb
new file mode 100644
index 000000000..58b349d83
--- /dev/null
+++ b/app/views/public/api/v1/portals/categories/show.html+documentation.erb
@@ -0,0 +1,25 @@
+<% content_for :head do %>
+ <%= render 'public/api/v1/portals/documentation_layout/categories/meta_head',
+ category: @category, portal: @portal, og_image_url: @og_image_url %>
+<% end %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/breadcrumb',
+ portal: @portal, locale: @locale, category: @category %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/categories/header',
+ category: @category, articles: @articles, category_authors: @category_authors %>
+
+ <% if @articles.empty? %>
+ <%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
+ <% else %>
+
+ <% @articles.each do |article| %>
+ <%= render 'public/api/v1/portals/documentation_layout/article_card',
+ portal: @portal,
+ article: article,
+ show_category: false %>
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb b/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb
new file mode 100644
index 000000000..6f66e7675
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb
@@ -0,0 +1,12 @@
+<%# locals: (portal:, article:, show_category: true) %>
+
+ <% if show_category && article.category %>
+
+ <% if article.category.icon.present? %><%= article.category.icon %><% end %>
+ <%= article.category.name %>
+
+ <% end %>
+ <%= article.title %>
+ <%= render_category_content(article.content) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb
new file mode 100644
index 000000000..e3632cb35
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (users:, size: :sm, overlap: '-ml-1.5') %>
+<% z_classes = %w[z-30 z-20 z-10] %>
+
+ <% users.first(3).each_with_index do |user, idx| %>
+ <%= render 'public/api/v1/portals/documentation_layout/user_avatar',
+ user: user,
+ size: size,
+ extra_class: "relative #{z_classes[idx]} #{idx.positive? ? overlap : ''}" %>
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb
new file mode 100644
index 000000000..3831e5dfe
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb
@@ -0,0 +1,31 @@
+<%# locals: (portal:, locale:, category: nil, article: nil) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb
new file mode 100644
index 000000000..fa4ffc024
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb
@@ -0,0 +1,24 @@
+<%# locals: (portal:, category:, contributors: []) %>
+<% contributors = Array(contributors) %>
+
+
+ <%= category.icon.presence || '📁' %>
+
+ <%= category.name %>
+ <% if category.description.present? %>
+ <%= category.description %>
+ <% end %>
+
+ <% if contributors.any? %>
+ <%= render 'public/api/v1/portals/documentation_layout/avatar_group',
+ users: contributors, size: :sm, overlap: '-ml-1.5' %>
+ <% else %>
+
+ <% end %>
+
+ <%= I18n.t('public_portal.sidebar.browse') %>
+
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb b/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb
new file mode 100644
index 000000000..4d1b53f25
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb
@@ -0,0 +1,3 @@
+
+
<%= local_assigns[:message] || I18n.t('public_portal.common.no_articles') %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb b/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb
new file mode 100644
index 000000000..1bb193451
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_footer.html.erb
@@ -0,0 +1,53 @@
+<%# locals: (portal:, global_config:) %>
+<%
+ socials = portal.social_profiles
+ social_definitions = [
+ { key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook', base_url: 'https://facebook.com/' },
+ { key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X', base_url: 'https://x.com/' },
+ { key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram', base_url: 'https://instagram.com/' },
+ { key: 'linkedin', icon: 'i-ri-linkedin-box-fill', label: 'LinkedIn', base_url: 'https://linkedin.com/' },
+ { key: 'youtube', icon: 'i-ri-youtube-fill', label: 'YouTube', base_url: 'https://youtube.com/' },
+ { key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok', base_url: 'https://tiktok.com/' },
+ { key: 'github', icon: 'i-ri-github-fill', label: 'GitHub', base_url: 'https://github.com/' },
+ { key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp', base_url: 'https://wa.me/' }
+ ]
+ active_socials = social_definitions.filter_map do |social|
+ handle = socials[social[:key]].to_s.strip
+ social.merge(url: "#{social[:base_url]}#{handle}") if handle.present?
+ end
+ show_branding = !portal.account.feature_enabled?('disable_branding')
+%>
+
+<% if show_branding || active_socials.any? %>
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
new file mode 100644
index 000000000..73d590518
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
@@ -0,0 +1,32 @@
+<%# locals: (portal:, popular_topics: []) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb
new file mode 100644
index 000000000..b1df7e6cf
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb
@@ -0,0 +1,6 @@
+
+ <%= title %>
+ <% if local_assigns[:subtitle].present? %>
+ <%= subtitle %>
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb
new file mode 100644
index 000000000..1898bbd46
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb
@@ -0,0 +1,89 @@
+<%# locals: (portal:, locale:, article: nil, category: nil) %>
+<%
+ current_category_slug = article&.category&.slug || category&.slug
+ current_article_slug = article&.slug
+ sidebar_categories = portal.categories.where(locale: locale).order(position: :asc)
+ sidebar_articles_by_category = portal.articles.published
+ .where(category_id: sidebar_categories.map(&:id))
+ .order(:position)
+ .group_by(&:category_id)
+ uncategorized_articles = portal.articles.published.where(category_id: nil, locale: locale).order(:position)
+%>
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
new file mode 100644
index 000000000..2b2f56547
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
@@ -0,0 +1,72 @@
+<%# locals: (portal:, locale:, article: nil, category: nil) %>
+<% home_url = public_portal_locale_path(portal.slug, locale) %>
+<% current = article ? :article : (category ? :category : :home) %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb
new file mode 100644
index 000000000..994222e0c
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb
@@ -0,0 +1,18 @@
+<%
+ size = local_assigns[:size] || :sm
+ border = local_assigns[:border] || 'ring-2 ring-solid ring-white dark:ring-n-slate-2'
+ extra = local_assigns[:extra_class] || ''
+ size_map = {
+ xs: { box: 'w-4 h-4', text: 'text-xs', rounded: 'rounded' },
+ sm: { box: 'w-6 h-6', text: 'text-xs', rounded: 'rounded-md' },
+ md: { box: 'w-7 h-7', text: 'text-xs', rounded: 'rounded-md' },
+ lg: { box: 'w-10 h-10', text: 'text-sm', rounded: 'rounded-xl' }
+ }
+ s = size_map.fetch(size, size_map[:sm])
+ name = user.available_name
+%>
+<% if user.avatar_url.present? %>
+
+<% else %>
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb
new file mode 100644
index 000000000..4993b4443
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb
@@ -0,0 +1,42 @@
+<%# locals: (portal:, article:) %>
+<%
+ markdown_url = public_portal_article_markdown_url(portal.slug, article.slug)
+ encoded_prompt = ERB::Util.url_encode(I18n.t('public_portal.article_actions.llm_prompt', url: markdown_url))
+ chatgpt_url = "https://chatgpt.com/?q=#{encoded_prompt}"
+ claude_url = "https://claude.ai/new?q=#{encoded_prompt}"
+%>
+
+
+
+ <%= I18n.t('public_portal.article_actions.label') %>
+
+
+
+
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb
new file mode 100644
index 000000000..e9e3fd407
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb
@@ -0,0 +1,32 @@
+<%# locals: (portal:, article:) %>
+<% last_updated_on = article.updated_at.strftime("%b %-d, %Y") %>
+
+
+
+ <%= article.title %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/articles/actions',
+ portal: portal, article: article %>
+
+
+ <% if article.author.present? %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/user_avatar',
+ user: article.author,
+ size: :lg,
+ border: 'border border-solid border-n-weak' %>
+
+
<%= article.author.available_name %>
+
+ <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
+
+
+
+ <% else %>
+
+
+ <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: last_updated_on) %>
+
+ <% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
new file mode 100644
index 000000000..a236722ae
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
@@ -0,0 +1,17 @@
+<%= article.title %> | <%= portal.display_title %>
+<% if article.meta["title"].present? %>
+ ">
+ ">
+<% end %>
+<% if article.meta["description"].present? %>
+ ">
+ ">
+<% end %>
+<% if article.meta["tags"].present? %>
+ ">
+<% end %>
+<% if og_image_url.present? %>
+
+
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb
new file mode 100644
index 000000000..471facc06
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb
@@ -0,0 +1,33 @@
+<% article_count_label = I18n.t(articles.size == 1 ? 'public_portal.common.article' : 'public_portal.common.articles') %>
+<% meta_row = capture do %>
+
+ <%= articles.size %> <%= article_count_label %>
+ <% if category_authors.any? %>
+ ·
+
+ <%= render 'public/api/v1/portals/documentation_layout/avatar_group',
+ users: category_authors, size: :xs, overlap: '-ml-0.5' %>
+ <%= I18n.t('public_portal.common.by') %> <%= format_authors_label(category_authors) %>
+
+ <% end %>
+
+<% end %>
+
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
new file mode 100644
index 000000000..7c8dec8d2
--- /dev/null
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
@@ -0,0 +1,11 @@
+<%= category.name %> | <%= portal.display_title %>
+
+<% if category.description.present? %>
+
+
+<% end %>
+<% if og_image_url.present? %>
+
+
+
+<% end %>
diff --git a/app/views/public/api/v1/portals/show.html+documentation.erb b/app/views/public/api/v1/portals/show.html+documentation.erb
new file mode 100644
index 000000000..d37d593dd
--- /dev/null
+++ b/app/views/public/api/v1/portals/show.html+documentation.erb
@@ -0,0 +1,34 @@
+<%= render 'public/api/v1/portals/documentation_layout/hero', portal: @portal, popular_topics: @popular_topics %>
+
+
+ <%= render 'public/api/v1/portals/documentation_layout/section_header',
+ title: I18n.t('public_portal.sidebar.browse_by_topic'),
+ subtitle: I18n.t('public_portal.sidebar.browse_by_topic_subtitle') %>
+
+ <% if @visible_categories.empty? %>
+ <%= render 'public/api/v1/portals/documentation_layout/empty_state' %>
+ <% else %>
+
+ <% @visible_categories.each do |category| %>
+ <%= render 'public/api/v1/portals/documentation_layout/category_card',
+ portal: @portal,
+ category: category,
+ contributors: @category_contributors[category.id] %>
+ <% end %>
+
+ <% end %>
+
+
+<% if @featured.any? %>
+
+ <%= render 'public/api/v1/portals/documentation_layout/section_header',
+ title: I18n.t('public_portal.sidebar.popular_articles'),
+ subtitle: I18n.t('public_portal.sidebar.popular_articles_subtitle'),
+ subtitle_class: 'mt-1.5 text-base text-n-slate-11' %>
+
+ <% @featured.each do |article| %>
+ <%= render 'public/api/v1/portals/documentation_layout/article_card', portal: @portal, article: article %>
+ <% end %>
+
+
+<% end %>
diff --git a/config/app.yml b/config/app.yml
index 5adfc1486..1c93a1287 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.13.0'
+ version: '4.14.0'
development:
<<: *shared
diff --git a/config/features.yml b/config/features.yml
index 92d004f81..03105588b 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -17,10 +17,10 @@
display_name: Facebook Channel
enabled: true
help_url: https://chwt.app/hc/fb
-- name: channel_twitter
- display_name: Twitter Channel
- enabled: true
- deprecated: true
+- name: conversation_unread_counts
+ display_name: Conversation Unread Counts
+ enabled: false
+ chatwoot_internal: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
@@ -219,11 +219,11 @@
- name: quoted_email_reply
display_name: Quoted Email Reply
enabled: false
+ deprecated: true
- name: companies
display_name: Companies
enabled: false
premium: true
- chatwoot_internal: true
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb
new file mode 100644
index 000000000..653b0c535
--- /dev/null
+++ b/config/initializers/active_storage.rb
@@ -0,0 +1,54 @@
+# Allow audio attachments (call recordings, voice notes) to serve inline so the
+# in-app