Merge develop into feat/voice-channel

Resolved conflicts:
- Voice.vue: Keep modern composition API version from develop
- ChannelItem.vue: Add voice channel feature flag check
- inboxes_controller.rb: Use allowed_channel_types method
- schema.rb: Add account_id index for voice channels
- inboxMgmt.json: Fix voice channel translations
- Note: Voice channel logic in base files removed in develop (moved to enterprise)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sojan
2025-06-25 16:04:27 -07:00
co-authored by Claude
49 changed files with 858 additions and 438 deletions
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -1,51 +1,21 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
modelValue: { type: [String, Number], default: '' },
type: { type: String, default: 'text' },
customInputClass: { type: [String, Object, Array], default: '' },
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
min: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -56,6 +26,10 @@ const emit = defineEmits([
'enter',
]);
// Generate a unique ID per component instance when `id` prop is not provided.
const { uid } = getCurrentInstance();
const uniqueId = computed(() => props.id || `input-${uid}`);
const isFocused = ref(false);
const inputRef = ref(null);
@@ -111,7 +85,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
:for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
@@ -119,7 +93,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:id="uniqueId"
ref="inputRef"
:value="modelValue"
:class="[
@@ -17,7 +17,7 @@ export default {
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
: ''
);
</script>
@@ -40,6 +40,11 @@ export default {
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -50,6 +55,7 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
},
@@ -299,6 +299,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -537,6 +537,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -157,6 +157,13 @@ const bulkCheckbox = computed({
},
});
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
@@ -270,7 +277,11 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
bulkSelectionState.hasSelected,
}"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
@@ -306,13 +317,18 @@ onMounted(() => {
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
class="flex items-center gap-3"
>
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
@@ -322,17 +338,23 @@ onMounted(() => {
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-2">
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
slate
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
slate
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
@@ -1,6 +1,7 @@
<script setup>
import IframeLoader from 'shared/components/IframeLoader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
url: {
@@ -11,6 +12,8 @@ defineProps({
const emit = defineEmits(['back', 'insert']);
const isRTL = useMapGetter('accounts/isRTL');
const onBack = e => {
e.stopPropagation();
emit('back');
@@ -35,7 +38,7 @@ const onInsert = e => {
</div>
<div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0">
<IframeLoader :url="url" />
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
</div>
</div>
@@ -37,6 +37,7 @@ export default {
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
{ key: 'instagram', name: 'Instagram' },
{ key: 'voice', name: 'Voice' },
];
},
...mapGetters({
@@ -1,278 +1,182 @@
<script>
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import router from '../../../../index';
import { isPhoneE164 } from 'shared/helpers/Validators';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import PageHeader from '../../SettingsSubPageHeader.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
// Using a regex validator for phone numbers
const validPhoneNumber = value => {
if (!value) return true;
return /^\+[1-9]\d{1,14}$/.test(value);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const state = reactive({
phoneNumber: '',
accountSid: '',
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
const validationRules = {
phoneNumber: { required, isPhoneE164 },
accountSid: { required },
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
provider: 'twilio',
phoneNumber: '',
accountSid: '',
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
providerOptions: [
{ value: 'twilio', label: 'Twilio' },
// Add more providers as needed
// { value: 'other_provider', label: 'Other Provider' },
],
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations() {
return {
phoneNumber: {
required,
validPhoneNumber,
},
accountSid: {
required: this.provider === 'twilio',
},
authToken: {
required: this.provider === 'twilio',
},
apiKeySid: {
required: this.provider === 'twilio',
},
apiKeySecret: {
required: this.provider === 'twilio',
},
// TwiML App SID is not required, but if provided it must follow Twilio's format
twimlAppSid: {
// Optional - will not be required
},
};
},
methods: {
onProviderChange() {
// Reset fields when provider changes
this.v$.$reset();
},
getProviderConfig() {
if (this.provider === 'twilio') {
const config = {
account_sid: this.accountSid,
auth_token: this.authToken,
api_key_sid: this.apiKeySid,
api_key_secret: this.apiKeySecret,
};
// Add the TwiML App SID if provided
if (this.twimlAppSid) {
config.outgoing_application_sid = this.twimlAppSid;
}
return config;
}
// Add handler for other providers here
return {};
},
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
const v$ = useVuelidate(validationRules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
try {
const providerConfig = this.getProviderConfig();
const formErrors = computed(() => ({
phoneNumber: v$.value.phoneNumber?.$error
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
: '',
accountSid: v$.value.accountSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
: '',
authToken: v$.value.authToken?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
: '',
apiKeySid: v$.value.apiKeySid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
: '',
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
const channel = await this.$store.dispatch(
'inboxes/createVoiceChannel',
{
voice: {
name: `Voice (${this.phoneNumber})`,
phone_number: this.phoneNumber,
provider: this.provider,
provider_config: JSON.stringify(providerConfig),
},
}
);
function getProviderConfig() {
const config = {
account_sid: state.accountSid,
auth_token: state.authToken,
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: channel.id,
},
});
} catch (error) {
useAlert(
error.response?.data?.message ||
this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
}
},
},
};
async function createChannel() {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
try {
const channel = await store.dispatch('inboxes/createVoiceChannel', {
name: `Voice (${state.phoneNumber})`,
voice: {
phone_number: state.phoneNumber,
provider: 'twilio',
provider_config: getProviderConfig(),
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: channel.id },
});
} catch (error) {
useAlert(
error.response?.data?.message ||
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
);
}
}
</script>
<template>
<div>
<div
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form
class="flex flex-wrap flex-col gap-4 p-2"
class="flex flex-col gap-4 flex-wrap mx-0"
@submit.prevent="createChannel"
>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
<select
v-model="provider"
class="p-2 bg-white border border-n-blue-100 rounded"
@change="onProviderChange"
>
<option
v-for="option in providerOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div>
<Input
v-model="state.phoneNumber"
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
:message="formErrors.phoneNumber"
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
@blur="v$.phoneNumber?.$touch"
/>
<!-- Twilio Provider Config -->
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
/>
<span v-if="v$.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<Input
v-model="state.accountSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
:message="formErrors.accountSid"
:message-type="formErrors.accountSid ? 'error' : 'info'"
@blur="v$.accountSid?.$touch"
/>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.accountSid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
<input
v-model.trim="accountSid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')
"
@blur="v$.accountSid.$touch"
/>
<span v-if="v$.accountSid.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
</span>
</label>
</div>
<Input
v-model="state.authToken"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
:message="formErrors.authToken"
:message-type="formErrors.authToken ? 'error' : 'info'"
@blur="v$.authToken?.$touch"
/>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
<input
v-model.trim="authToken"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')
"
@blur="v$.authToken.$touch"
/>
<span v-if="v$.authToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
</span>
</label>
</div>
<Input
v-model="state.apiKeySid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
:message="formErrors.apiKeySid"
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
@blur="v$.apiKeySid?.$touch"
/>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKeySid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL', 'API Key SID') }}
<input
v-model.trim="apiKeySid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER', 'Enter your Twilio API Key SID')
"
@blur="v$.apiKeySid.$touch"
/>
<span v-if="v$.apiKeySid.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED', 'API Key SID is required') }}
</span>
<span class="help-text">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.HELP', 'You can create API keys in the Twilio Console') }}
</span>
</label>
</div>
<Input
v-model="state.apiKeySecret"
type="password"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
:message="formErrors.apiKeySecret"
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
@blur="v$.apiKeySecret?.$touch"
/>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKeySecret.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL', 'API Key Secret') }}
<input
v-model.trim="apiKeySecret"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER', 'Enter your Twilio API Key Secret')
"
@blur="v$.apiKeySecret.$touch"
/>
<span v-if="v$.apiKeySecret.$error" class="message">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED', 'API Key Secret is required') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL', 'TwiML App SID (Recommended)') }}
<input
v-model.trim="twimlAppSid"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER', 'Enter your Twilio TwiML App SID (starts with AP)')
"
/>
<span class="help-text">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.HELP', 'Required for browser-based calling. Create a TwiML App in the Twilio Console with Voice URLs pointing to your Chatwoot instance.') }}
</span>
</label>
</div>
</div>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<!-- Add other provider configs here -->
<div class="mt-4">
<div>
<NextButton
:is-loading="uiFlags.isCreating"
:is-disabled="v$.$invalid"
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
:disabled="isSubmitDisabled"
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
color="blue"
@click="createChannel"
/>
</div>
</form>
@@ -75,10 +75,34 @@ export default {
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
onRequestPermissions(value) {
if (value) {
// Enable / re-enable push notifications
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
} else {
// Disable push notifications
this.disablePushPermissions();
}
},
disablePushPermissions() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (subscription) {
return subscription.unsubscribe();
}
return null;
})
.finally(() => {
this.hasEnabledPushPermissions = false;
})
.catch(() => {
// error
})
);
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
cellProps.getValue()
);
const columns = [
const columns = computed(() => [
columnHelper.accessor('name', {
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
width: 300,
@@ -90,7 +90,7 @@ const columns = [
width: 200,
cell: defaulSpanRender,
}),
];
]);
const renderAvgTime = value => (value ? formatTime(value) : '--');
@@ -142,7 +142,9 @@ const table = useVueTable({
get data() {
return tableData.value;
},
columns,
get columns() {
return columns.value;
},
enableSorting: false,
getCoreRowModel: getCoreRowModel(),
});
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -253,6 +231,12 @@ export const actions = {
throw new Error(error);
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
// - createWebsiteChannel
// - createTwilioChannel
// - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
@@ -0,0 +1,52 @@
import * as types from '../../mutation-types';
import InboxesAPI from '../../../api/inboxes';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const channelActions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await InboxesAPI.create({
name: params.name,
channel: { ...params.voice, type: 'voice' },
});
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
};
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
describe('#createVoiceChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
await actions.createVoiceChannel({ commit }, inboxList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.ADD_INBOXES, inboxList[0]],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
+7 -4
View File
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
},
setDirectionAttribute: () => {
const portalElement = document.getElementById('portal');
if (!portalElement) return;
const htmlElement = document.querySelector('html');
// If direction is already applied through props, do not apply again (iframe case)
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
if (!htmlElement || hasDirApplied) return;
const locale = document.querySelector('.locale-switcher')?.value;
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
const localeFromHtml = htmlElement.lang;
htmlElement.dir =
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
@@ -1,64 +1,56 @@
<script>
<script setup>
import { ref, useTemplateRef, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
export default {
name: 'IframeLoader',
components: {
ArticleSkeletonLoader,
const props = defineProps({
url: {
type: String,
default: '',
},
props: {
url: {
type: String,
default: '',
},
isRtl: {
type: Boolean,
default: false,
},
isRtl: {
type: Boolean,
default: false,
},
data() {
return {
isLoading: true,
showEmptyState: !this.url,
};
isDirApplied: {
type: Boolean,
default: false,
},
watch: {
isRtl: {
immediate: true,
handler(value) {
this.$nextTick(() => {
const iframeElement = this.$el.querySelector('iframe');
if (iframeElement) {
iframeElement.onload = () => {
try {
const iframeDocument =
iframeElement.contentDocument ||
(iframeElement.contentWindow &&
iframeElement.contentWindow.document);
});
if (iframeDocument) {
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
}
} catch (e) {
// error
}
};
}
});
},
},
},
methods: {
handleIframeLoad() {
// Once loaded, the loading state is hidden
this.isLoading = false;
},
handleIframeError() {
// Hide the loading state and show the empty state when an error occurs
this.isLoading = false;
this.showEmptyState = true;
},
},
const { t } = useI18n();
const iframe = useTemplateRef('iframe');
const isLoading = ref(true);
const showEmptyState = ref(!props.url);
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
const applyDirection = () => {
if (!iframe.value) return;
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
try {
const doc =
iframe.value.contentDocument || iframe.value.contentWindow?.document;
if (doc?.documentElement) {
doc.documentElement.dir = direction.value;
doc.documentElement.setAttribute('data-dir-applied', 'true');
}
} catch (e) {
// error
}
};
watch(() => props.isRtl, applyDirection);
const handleIframeLoad = () => {
isLoading.value = false;
applyDirection();
};
const handleIframeError = () => {
isLoading.value = false;
showEmptyState.value = true;
};
</script>
@@ -66,6 +58,7 @@ export default {
<div class="relative overflow-hidden pb-1/2 h-full">
<iframe
v-if="url"
ref="iframe"
:src="url"
class="absolute w-full h-full top-0 left-0"
@load="handleIframeLoad"
@@ -79,7 +72,7 @@ export default {
v-if="showEmptyState"
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
>
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
/**
* Determine the best-matching locale from the list of locales allowed by the portal.
*
* The matching happens in the following order:
* 1. Exact match the visitor-selected locale equals one in the `allowedLocales` list
* (e.g., `fr` ➜ `fr`).
* 2. Base language match the base part of a compound locale (before the underscore)
* matches (e.g., `fr_CA` ➜ `fr`).
* 3. Variant match when the base language is selected but a regional variant exists
* in the portal list (e.g., `fr` ➜ `fr_BE`).
*
* If none of these rules find a match, the function returns `null`,
* Don't show popular articles if locale doesn't match with allowed locales
*
* @export
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
* @param {string[]} allowedLocales Array of locales enabled for the portal.
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
*/
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
// Ensure inputs are valid
if (
!selectedLocale ||
!Array.isArray(allowedLocales) ||
!allowedLocales.length
) {
return null;
}
const [lang] = selectedLocale.split('_');
const priorityMatches = [
selectedLocale, // exact match
lang, // base language match
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
];
// Return the first match that exists in the allowed list, or null
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
};
@@ -0,0 +1,28 @@
import { getMatchingLocale } from 'shared/helpers/portalHelper';
describe('portalHelper - getMatchingLocale', () => {
it('returns exact match when present', () => {
const result = getMatchingLocale('fr', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns base language match when exact variant not present', () => {
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
expect(result).toBe('fr');
});
it('returns variant match when base language not present', () => {
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
expect(result).toBe('fr_BE');
});
it('returns null when no match found', () => {
const result = getMatchingLocale('de', ['en', 'fr']);
expect(result).toBeNull();
});
it('returns null for invalid inputs', () => {
expect(getMatchingLocale('', [])).toBeNull();
expect(getMatchingLocale(null, null)).toBeNull();
});
});
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
const {
allowed_locales: allowedLocales,
default_locale: defaultLocale = 'en',
} = portal.value.config;
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
// If the exact match of a locale is available in the list of portal locales, return it
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
if (allowedLocales.includes(selectedLocale)) {
return locale;
}
return defaultLocale;
const { allowed_locales: allowedLocales } = portal.value.config;
return getMatchingLocale(selectedLocale.value, allowedLocales);
});
const fetchArticles = () => {
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -64,7 +57,8 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
!!popularArticles.value.length
!!popularArticles.value.length &&
!!locale.value
);
onMounted(() => fetchArticles());
</script>
@@ -23,6 +23,7 @@ export const actions = {
commit('setError', false);
try {
if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
@@ -1,24 +1,16 @@
<script>
import IframeLoader from 'shared/components/IframeLoader.vue';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
export default {
name: 'ArticleViewer',
components: {
IframeLoader,
},
computed: {
isRTL() {
return this.$root.$i18n.locale
? getLanguageDirection(this.$root.$i18n.locale)
: false;
},
},
};
</script>
<template>
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
<IframeLoader :url="$route.query.link" />
</div>
</template>