Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882fd8be78 | ||
|
|
a59598ee15 | ||
|
|
8e499904d0 | ||
|
|
4d6810722e | ||
|
|
bf0a10c780 | ||
|
|
a752e56765 | ||
|
|
9b58e8940a | ||
|
|
b17ff51c8e | ||
|
|
a25113cea5 | ||
|
|
86582569ee |
@@ -0,0 +1,77 @@
|
||||
class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController
|
||||
before_action :authorize_create, only: [:preview, :connect]
|
||||
before_action :fetch_inbox, only: [:webhook_status, :setup_webhook]
|
||||
|
||||
def preview
|
||||
render json: validation_service.perform
|
||||
rescue StandardError => e
|
||||
render_setup_error(e)
|
||||
end
|
||||
|
||||
def connect
|
||||
setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform
|
||||
render json: connection_response(setup), status: :created
|
||||
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||
render_error_response(e)
|
||||
rescue StandardError => e
|
||||
render_setup_error(e)
|
||||
end
|
||||
|
||||
def webhook_status
|
||||
render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform
|
||||
rescue StandardError => e
|
||||
render_setup_error(e)
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
channel = @inbox.channel
|
||||
Whatsapp::WebhookSetupService.new(channel).register_callback
|
||||
render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform
|
||||
rescue StandardError => e
|
||||
render_setup_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_create
|
||||
authorize ::Inbox, :create?
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :update?
|
||||
channel = @inbox.channel
|
||||
return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2'
|
||||
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
|
||||
def validation_service
|
||||
Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys)
|
||||
end
|
||||
|
||||
def connection_params
|
||||
params.permit(:waba_id, :phone_number_id, :access_token)
|
||||
end
|
||||
|
||||
def connect_params
|
||||
params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name)
|
||||
end
|
||||
|
||||
def connection_response(setup)
|
||||
channel = setup.channel.reload
|
||||
{
|
||||
id: channel.inbox.id,
|
||||
name: channel.inbox.name,
|
||||
number_access: true,
|
||||
template_access: true,
|
||||
webhook_setup: setup.webhook_setup?,
|
||||
webhook_error: setup.webhook_error
|
||||
}
|
||||
end
|
||||
|
||||
def render_setup_error(error)
|
||||
Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}"
|
||||
render json: { message: error.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(params = {}) {
|
||||
return axios.get(this.url, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CallsAPI();
|
||||
@@ -16,6 +16,26 @@ class WhatsappChannel extends ApiClient {
|
||||
inbox_id: inboxId,
|
||||
});
|
||||
}
|
||||
|
||||
previewManualSetup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/manual/preview`, params);
|
||||
}
|
||||
|
||||
connectManualSetup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/manual/connect`, params);
|
||||
}
|
||||
|
||||
getManualWebhookStatus(inboxId) {
|
||||
return axios.get(
|
||||
`${this.baseUrl()}/whatsapp/manual/${inboxId}/webhook_status`
|
||||
);
|
||||
}
|
||||
|
||||
setupManualWebhook(inboxId) {
|
||||
return axios.post(
|
||||
`${this.baseUrl()}/whatsapp/manual/${inboxId}/setup_webhook`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappChannel();
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||
import {
|
||||
VOICE_CALL_DIRECTION,
|
||||
VOICE_CALL_STATUS,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import CallStatusBadge from './CallStatusBadge.vue';
|
||||
import { CALL_KIND, getCallKind } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
call: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const kind = computed(() => getCallKind(props.call));
|
||||
|
||||
const contactName = computed(
|
||||
() => props.call.contact.name || props.call.contact.phoneNumber
|
||||
);
|
||||
|
||||
const agentActionLabel = computed(() => {
|
||||
if (!props.call.agent) return '';
|
||||
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
|
||||
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
|
||||
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
|
||||
if (kind.value === CALL_KIND.ONGOING) {
|
||||
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
? t('CALLS_PAGE.ROW.DIALED_BY')
|
||||
: t('CALLS_PAGE.ROW.PICKED_BY');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const resultLabel = computed(() => {
|
||||
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
|
||||
if (kind.value === CALL_KIND.NO_REPLY) {
|
||||
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
|
||||
}
|
||||
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
|
||||
if (kind.value === CALL_KIND.ONGOING) {
|
||||
return props.call.status === VOICE_CALL_STATUS.RINGING
|
||||
? t('CALLS_PAGE.ROW.RINGING')
|
||||
: t('CALLS_PAGE.ROW.IN_PROGRESS');
|
||||
}
|
||||
return t('CALLS_PAGE.ROW.ANSWERED');
|
||||
});
|
||||
|
||||
const providerIcon = computed(() =>
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
);
|
||||
|
||||
const createdAtLabel = computed(() =>
|
||||
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
|
||||
);
|
||||
|
||||
const conversationRoute = computed(() => ({
|
||||
name: 'inbox_conversation',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
conversation_id: props.call.conversation.displayId,
|
||||
},
|
||||
query: { messageId: props.call.messageId },
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
|
||||
>
|
||||
{{ contactName }}
|
||||
</span>
|
||||
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
|
||||
<RouterLink
|
||||
:to="conversationRoute"
|
||||
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
|
||||
>
|
||||
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||
{{ call.conversation.displayId }}
|
||||
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<template v-if="agentActionLabel">
|
||||
<span class="text-label-small text-n-slate-10 shrink-0">
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="call.agent.avatar"
|
||||
:name="call.agent.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-body-main truncate text-n-slate-12 min-w-0">
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
|
||||
{{ resultLabel }}
|
||||
</span>
|
||||
<span class="w-px h-3 bg-n-strong shrink-0" />
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!call.recordingUrl"
|
||||
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="call.recordingUrl"
|
||||
class="flex items-center gap-2 min-w-0 justify-between"
|
||||
>
|
||||
<AudioPlayer
|
||||
:src="call.recordingUrl"
|
||||
:fallback-duration="call.durationSeconds || 0"
|
||||
class="flex-1 sm:flex-[0.7] min-w-0"
|
||||
/>
|
||||
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||
class="text-heading-3 font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ contactName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
|
||||
>
|
||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||
<template v-if="agentActionLabel">
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
>
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
|
||||
<Avatar
|
||||
:src="call.agent.avatar"
|
||||
:name="call.agent.name"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-tooltip.top="{
|
||||
content: call.agent.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-body-main truncate text-n-slate-12 min-w-0"
|
||||
>
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="resultLabel"
|
||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||
>
|
||||
{{ resultLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<AudioPlayer
|
||||
v-if="call.recordingUrl"
|
||||
:src="call.recordingUrl"
|
||||
:fallback-duration="call.durationSeconds || 0"
|
||||
class="w-auto min-w-44 shrink mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: call.inbox.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||
>
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="conversationRoute"
|
||||
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
|
||||
>
|
||||
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||
{{ call.conversation.displayId }}
|
||||
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<span
|
||||
v-tooltip.top="{
|
||||
content: createdAtLabel,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fallbackDuration: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(props.fallbackDuration);
|
||||
const playbackSpeed = ref(1);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const loadedDuration = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||
};
|
||||
|
||||
const formatTime = time => {
|
||||
if (!time || Number.isNaN(time)) return '00:00';
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||
|
||||
const displayedTime = computed(() =>
|
||||
formatTime(
|
||||
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||
)
|
||||
);
|
||||
|
||||
// Only one recording should play at a time across the list.
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
if (currentPlayingId !== uid && isPlaying.value) {
|
||||
audioPlayer.value?.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playOrPause = () => {
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
emitter.emit('pause_playing_audio', uid);
|
||||
audioPlayer.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = audioPlayer.value?.currentTime;
|
||||
};
|
||||
|
||||
const seek = event => {
|
||||
const time = Number(event.target.value);
|
||||
audioPlayer.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = () => {
|
||||
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||
playbackSpeed.value =
|
||||
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
};
|
||||
|
||||
const downloadRecording = () => {
|
||||
downloadFile({ url: props.src, type: 'audio' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="src" />
|
||||
</audio>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-12"
|
||||
@click="playOrPause"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:label="playbackSpeedLabel"
|
||||
class="!px-1 min-w-6 !text-n-slate-11"
|
||||
@click="changePlaybackSpeed"
|
||||
/>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-11"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { CALL_KIND } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
kind: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const KIND_CONFIG = {
|
||||
[CALL_KIND.ONGOING]: {
|
||||
icon: 'i-lucide-phone-call',
|
||||
class: 'bg-n-teal-3 text-n-teal-11',
|
||||
},
|
||||
[CALL_KIND.INCOMING]: {
|
||||
icon: 'i-lucide-phone-incoming',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
[CALL_KIND.OUTGOING]: {
|
||||
icon: 'i-lucide-phone-outgoing',
|
||||
class: 'bg-n-slate-3 text-n-slate-11',
|
||||
},
|
||||
[CALL_KIND.MISSED]: {
|
||||
icon: 'i-lucide-phone-missed',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
[CALL_KIND.NO_REPLY]: {
|
||||
icon: 'i-lucide-phone-outgoing',
|
||||
class: 'bg-n-amber-3 text-n-amber-11',
|
||||
},
|
||||
[CALL_KIND.FAILED]: {
|
||||
icon: 'i-lucide-phone-off',
|
||||
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
},
|
||||
};
|
||||
|
||||
const config = computed(() => KIND_CONFIG[props.kind]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
|
||||
:class="config.class"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
|
||||
<span class="truncate">{{
|
||||
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const setupVoiceChannel = () => {
|
||||
router.push({
|
||||
name: 'settings_inbox_new',
|
||||
params: { accountId: route.params.accountId },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="t('CALLS_PAGE.SETUP.TITLE')"
|
||||
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="t('CALLS_PAGE.SETUP.ACTION')"
|
||||
icon="i-lucide-plus"
|
||||
@click="setupVoiceChannel"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Null while a fetch is in flight so stale counts are never shown.
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
agents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inboxes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Self-scoped viewers only ever see their own calls, so the assignee filter
|
||||
// is meaningless for them — only admins get it.
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const activity = defineModel('activity', { type: String, default: null });
|
||||
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
|
||||
const inboxId = defineModel('inboxId', { type: Number, default: null });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ACTIVITY_ICONS = {
|
||||
missed: 'i-lucide-phone-missed',
|
||||
no_reply: 'i-lucide-phone-outgoing',
|
||||
incoming: 'i-lucide-phone-incoming',
|
||||
outgoing: 'i-lucide-phone-outgoing',
|
||||
in_progress: 'i-lucide-phone-call',
|
||||
};
|
||||
|
||||
const BASE_ACTIVITIES = ['missed', 'no_reply'];
|
||||
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
|
||||
|
||||
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
|
||||
// opening one closes the others without any cross-wiring.
|
||||
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
|
||||
|
||||
const toggleMenu = name => {
|
||||
openMenu.value = openMenu.value === name ? null : name;
|
||||
};
|
||||
|
||||
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
|
||||
// other two wrappers (which the click is also outside of) from closing a
|
||||
// menu the user is interacting with.
|
||||
const closeOnOutside = name => {
|
||||
if (openMenu.value === name) openMenu.value = null;
|
||||
};
|
||||
|
||||
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
|
||||
|
||||
const activeChipLabel = computed(() => {
|
||||
const label = activityLabel(activity.value);
|
||||
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
|
||||
});
|
||||
|
||||
const inactiveChips = computed(() =>
|
||||
BASE_ACTIVITIES.filter(value => value !== activity.value)
|
||||
);
|
||||
|
||||
const otherActivityItems = computed(() =>
|
||||
OTHER_ACTIVITIES.map(value => ({
|
||||
label: activityLabel(value),
|
||||
value,
|
||||
action: 'filter',
|
||||
icon: ACTIVITY_ICONS[value],
|
||||
isSelected: activity.value === value,
|
||||
}))
|
||||
);
|
||||
|
||||
const assigneeItems = computed(() => [
|
||||
{
|
||||
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
|
||||
value: null,
|
||||
action: 'filter',
|
||||
isSelected: !assigneeId.value,
|
||||
},
|
||||
...props.agents.map(agent => ({
|
||||
label: agent.name,
|
||||
value: agent.id,
|
||||
action: 'filter',
|
||||
thumbnail: { name: agent.name, src: agent.thumbnail },
|
||||
isSelected: assigneeId.value === agent.id,
|
||||
})),
|
||||
]);
|
||||
|
||||
const moreFiltersSections = computed(() => [
|
||||
{
|
||||
title: t('CALLS_PAGE.FILTERS.INBOX'),
|
||||
items: [
|
||||
{
|
||||
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
|
||||
value: null,
|
||||
action: 'inbox',
|
||||
isSelected: !inboxId.value,
|
||||
},
|
||||
...props.inboxes.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'inbox',
|
||||
isSelected: inboxId.value === inbox.id,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedAssigneeLabel = computed(
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||
|
||||
const setActivity = value => {
|
||||
openMenu.value = null;
|
||||
activity.value = value;
|
||||
};
|
||||
|
||||
const setAssignee = ({ value }) => {
|
||||
openMenu.value = null;
|
||||
assigneeId.value = value;
|
||||
};
|
||||
|
||||
const applyMoreFilter = ({ action, value }) => {
|
||||
openMenu.value = null;
|
||||
if (action === 'inbox') inboxId.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||
{{
|
||||
totalCount === null
|
||||
? t('CALLS_PAGE.ALL_CALLS')
|
||||
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
|
||||
}}
|
||||
</span>
|
||||
<Button
|
||||
v-else
|
||||
variant="outline"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[activity]"
|
||||
class="shrink-0"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||
<Button
|
||||
v-for="chip in inactiveChips"
|
||||
:key="chip"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[chip]"
|
||||
:label="activityLabel(chip)"
|
||||
class="shrink-0 text-n-slate-12"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
class="relative shrink-0"
|
||||
@trigger="closeOnOutside('activity')"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="text-n-slate-12"
|
||||
@click="toggleMenu('activity')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'activity'"
|
||||
:menu-items="otherActivityItems"
|
||||
class="mt-1 start-0 top-full w-44"
|
||||
@action="setActivity($event.value)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<OnClickOutside
|
||||
v-if="showAssignee"
|
||||
class="relative"
|
||||
@trigger="closeOnOutside('assignee')"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'assignee'"
|
||||
:menu-items="assigneeItems"
|
||||
show-search
|
||||
class="mt-1 end-0 top-full w-56 max-h-72"
|
||||
@action="setAssignee"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list-filter"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
@click="toggleMenu('more')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||
/>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openMenu === 'more'"
|
||||
:menu-sections="moreFiltersSections"
|
||||
class="mt-1 end-0 top-full w-56 max-h-80"
|
||||
@action="applyMoreFilter"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
|
||||
export const CALL_KIND = {
|
||||
ONGOING: 'ongoing',
|
||||
INCOMING: 'incoming',
|
||||
OUTGOING: 'outgoing',
|
||||
MISSED: 'missed',
|
||||
NO_REPLY: 'no_reply',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
// The API returns display values: status (ringing/in-progress/completed/
|
||||
// no-answer/failed) and direction (inbound/outbound). The list UI presents
|
||||
// them as a single "kind" per row.
|
||||
export const getCallKind = call => {
|
||||
if (
|
||||
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
|
||||
call.status
|
||||
)
|
||||
) {
|
||||
return CALL_KIND.ONGOING;
|
||||
}
|
||||
if (
|
||||
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
|
||||
) {
|
||||
return CALL_KIND.FAILED;
|
||||
}
|
||||
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
|
||||
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
|
||||
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
|
||||
}
|
||||
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
|
||||
};
|
||||
|
||||
// Filter chips map to the status/direction params supported by CallFinder.
|
||||
export const CALL_ACTIVITY_PARAMS = {
|
||||
missed: {
|
||||
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||
direction: VOICE_CALL_DIRECTION.INBOUND,
|
||||
},
|
||||
no_reply: {
|
||||
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||
direction: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||
},
|
||||
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
|
||||
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
|
||||
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fallbackDuration: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||
|
||||
const audioPlayer = useTemplateRef('audioPlayer');
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const isPlaying = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(props.fallbackDuration);
|
||||
const playbackSpeed = ref(1);
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const loadedDuration = audioPlayer.value?.duration;
|
||||
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||
};
|
||||
|
||||
const formatTime = time => {
|
||||
if (!time || Number.isNaN(time)) return '00:00';
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||
|
||||
const displayedTime = computed(() =>
|
||||
formatTime(
|
||||
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||
)
|
||||
);
|
||||
|
||||
// Only one recording should play at a time across the list.
|
||||
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||
if (currentPlayingId !== uid && isPlaying.value) {
|
||||
audioPlayer.value?.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const playOrPause = () => {
|
||||
if (isPlaying.value) {
|
||||
audioPlayer.value.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
emitter.emit('pause_playing_audio', uid);
|
||||
audioPlayer.value.play();
|
||||
isPlaying.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
currentTime.value = audioPlayer.value?.currentTime;
|
||||
};
|
||||
|
||||
const seek = event => {
|
||||
const time = Number(event.target.value);
|
||||
audioPlayer.value.currentTime = time;
|
||||
currentTime.value = time;
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
};
|
||||
|
||||
const changePlaybackSpeed = () => {
|
||||
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||
playbackSpeed.value =
|
||||
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
};
|
||||
|
||||
const downloadRecording = () => {
|
||||
downloadFile({ url: props.src, type: 'audio' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="src" />
|
||||
</audio>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-12"
|
||||
@click="playOrPause"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:label="playbackSpeedLabel"
|
||||
class="!px-1 min-w-6 !text-n-slate-11"
|
||||
@click="changePlaybackSpeed"
|
||||
/>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="!w-6 !p-0 text-n-slate-11"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
@@ -43,7 +44,14 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const store = useStore();
|
||||
|
||||
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
|
||||
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
|
||||
const isCallsAvailable = computed(
|
||||
() => isOnChatwootCloud.value || isEnterprise
|
||||
);
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCallsAvailable.value
|
||||
? [
|
||||
{
|
||||
name: 'Calls',
|
||||
label: t('SIDEBAR.CALLS'),
|
||||
icon: 'i-lucide-phone',
|
||||
to: accountScopedRoute('calls_dashboard_index'),
|
||||
activeOn: ['calls_dashboard_index'],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Contacts',
|
||||
label: t('SIDEBAR.CONTACTS'),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"CALLS_PAGE": {
|
||||
"HEADER": "Calls",
|
||||
"ALL_CALLS": "All Calls",
|
||||
"ALL_CALLS_COUNT": "All Calls ({count})",
|
||||
"EMPTY_STATE": "No calls found",
|
||||
"SETUP": {
|
||||
"TITLE": "Make and receive calls in one place",
|
||||
"SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.",
|
||||
"ACTION": "Set up voice channel"
|
||||
},
|
||||
"FILTERS": {
|
||||
"MISSED": "Missed",
|
||||
"NO_REPLY": "No reply",
|
||||
"OTHER_ACTIVITY": "Other activity",
|
||||
"INCOMING": "Incoming",
|
||||
"OUTGOING": "Outgoing",
|
||||
"IN_PROGRESS": "In progress",
|
||||
"ASSIGNEE": "Assignee",
|
||||
"ALL_ASSIGNEES": "All assignees",
|
||||
"MORE_FILTERS": "More filters",
|
||||
"INBOX": "Inbox",
|
||||
"ALL_INBOXES": "All inboxes"
|
||||
},
|
||||
"STATUS": {
|
||||
"ONGOING": "Ongoing",
|
||||
"INCOMING": "Incoming",
|
||||
"OUTGOING": "Outgoing",
|
||||
"MISSED": "Missed",
|
||||
"NO_REPLY": "No reply",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"ROW": {
|
||||
"PICKED_BY": "Picked by",
|
||||
"DIALED_BY": "Dialed by",
|
||||
"ANSWERED": "Answered",
|
||||
"RINGING": "Ringing",
|
||||
"IN_PROGRESS": "In progress",
|
||||
"NO_AGENT": "No agent answered this call",
|
||||
"NO_CONTACT_ANSWER": "Contact did not answer",
|
||||
"FAILED": "This call could not be connected",
|
||||
"YESTERDAY": "Yesterday"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,100 @@
|
||||
"WEBHOOK_URL": "Webhook URL",
|
||||
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
|
||||
},
|
||||
"MANUAL_SETUP": {
|
||||
"HEADER": {
|
||||
"TITLE": "Connect WhatsApp manually",
|
||||
"DESCRIPTION": "Follow these steps to prepare your Meta account and connect your number to Chatwoot."
|
||||
},
|
||||
"APP": {
|
||||
"TITLE": "Create or select a Meta app",
|
||||
"DESCRIPTION": "Your WhatsApp number must belong to a Meta app with the WhatsApp use case enabled.",
|
||||
"ITEM_1": "Open {metaDevelopers} and sign in with an administrator account.",
|
||||
"META_DEVELOPERS": "Meta Developers",
|
||||
"ITEM_2": "Create a new app, or select the app you already use for this WhatsApp number.",
|
||||
"ITEM_3": "Choose the option to connect with customers through WhatsApp.",
|
||||
"ITEM_4": "Select the business portfolio that owns, or will own, the WhatsApp number.",
|
||||
"VIDEO_TITLE": "Watch: Create a Meta app",
|
||||
"VIDEO_DESCRIPTION": "This short walkthrough shows where to start a new app in Meta Developers."
|
||||
},
|
||||
"NUMBER": {
|
||||
"TITLE": "Add your phone number and get its IDs",
|
||||
"DESCRIPTION": "Add and verify the production number in your Meta app, then copy the two identifiers shown in API Setup.",
|
||||
"ITEM_1": "Open the WhatsApp use case in your Meta app and choose API Setup.",
|
||||
"ITEM_2": "In the Send and receive messages section, open the From selector.",
|
||||
"ITEM_3": "Select an existing production number, or choose Add phone number.",
|
||||
"ITEM_4": "Complete the WhatsApp business profile requested by Meta.",
|
||||
"ITEM_5": "Verify the phone number using the OTP sent by SMS or voice call.",
|
||||
"ITEM_6": "Copy the Phone Number ID and WhatsApp Business Account ID shown in API Setup.",
|
||||
"VIDEO_TITLE": "Watch: Add a phone number and find its IDs",
|
||||
"VIDEO_DESCRIPTION": "This walkthrough shows how to add or select a production number and copy the identifiers from Meta."
|
||||
},
|
||||
"TOKEN": {
|
||||
"TITLE": "Generate a permanent access token",
|
||||
"DESCRIPTION": "Create a Meta system user with access to your app and WhatsApp Business Account.",
|
||||
"ITEM_1": "Open Meta Business Settings and go to Users → System users.",
|
||||
"ITEM_2": "Create an admin system user, or select an existing one.",
|
||||
"ITEM_3": "Assign your Meta app and WhatsApp Business Account to the system user.",
|
||||
"ITEM_4": "Grant full control for the assigned WhatsApp assets.",
|
||||
"ITEM_5": "Generate a token for your Meta app and set its expiration to Never.",
|
||||
"ITEM_6": "Select whatsapp_business_management and whatsapp_business_messaging, then copy the token.",
|
||||
"VIDEO_TITLE": "Watch: Generate a permanent access token",
|
||||
"VIDEO_DESCRIPTION": "This walkthrough shows how to select your Meta app, choose a non-expiring token, and grant the required WhatsApp permissions.",
|
||||
"WARNING": "Meta shows the token only once. Copy it before closing the dialog."
|
||||
},
|
||||
"DETAILS": {
|
||||
"WABA_LABEL": "WhatsApp Business Account ID",
|
||||
"WABA_PLACEHOLDER": "Enter WABA ID",
|
||||
"PHONE_ID_LABEL": "Phone Number ID",
|
||||
"PHONE_ID_PLACEHOLDER": "Enter Phone Number ID",
|
||||
"TOKEN_LABEL": "Permanent access token",
|
||||
"TOKEN_PLACEHOLDER": "Paste access token"
|
||||
},
|
||||
"REVIEW": {
|
||||
"TITLE": "Create your WhatsApp inbox",
|
||||
"DESCRIPTION": "Review the number details and choose a name for the inbox.",
|
||||
"VERIFIED": "Your Meta number and access token are valid.",
|
||||
"BUSINESS_NAME": "Business name",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"PHONE_ID": "Phone Number ID",
|
||||
"WABA_ID": "WhatsApp Business Account ID",
|
||||
"INBOX_NAME": "Inbox name",
|
||||
"INBOX_NAME_HELP": "We generated this name from your verified Meta business name. You can change it."
|
||||
},
|
||||
"VERIFY": {
|
||||
"TITLE": "Verify your connection",
|
||||
"DESCRIPTION": "Chatwoot is checking number access and configuring your Meta webhook.",
|
||||
"NUMBER_ACCESS": "Number access",
|
||||
"TEMPLATE_ACCESS": "Template access",
|
||||
"CALLBACK": "Webhook callback",
|
||||
"SUBSCRIPTION": "Webhook subscription",
|
||||
"WEBHOOK_URL": "Webhook URL",
|
||||
"COPY": "Copy",
|
||||
"COPY_SUCCESS": "Webhook URL copied to clipboard",
|
||||
"COMPLETE": "Verified",
|
||||
"PENDING": "Pending",
|
||||
"SUCCESS": "Your WhatsApp number is connected and ready for agent assignment."
|
||||
},
|
||||
"ACTIONS": {
|
||||
"BACK": "Back",
|
||||
"OPEN_META_APPS": "Open Meta Apps",
|
||||
"APP_READY": "My Meta app is ready",
|
||||
"NEXT": "Next",
|
||||
"OPEN_BUSINESS_SETTINGS": "Open Meta Business Settings",
|
||||
"SHOW_TOKEN": "Show token",
|
||||
"HIDE_TOKEN": "Hide token",
|
||||
"VERIFY_DETAILS": "Verify details",
|
||||
"CONNECT": "Create inbox",
|
||||
"RETRY_WEBHOOK": "Retry webhook setup",
|
||||
"CONTINUE": "Continue to add agents"
|
||||
},
|
||||
"ERRORS": {
|
||||
"IDS_REQUIRED": "Enter the Phone Number ID and WhatsApp Business Account ID.",
|
||||
"REQUIRED": "Enter the WABA ID, Phone Number ID, and permanent access token.",
|
||||
"INVALID_TOKEN": "This access token is invalid or expired. Generate a new permanent token and try again.",
|
||||
"GENERIC": "We could not complete the WhatsApp setup. Check your details and try again."
|
||||
}
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick setup with Meta",
|
||||
|
||||
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
|
||||
import auditLogs from './auditLogs.json';
|
||||
import automation from './automation.json';
|
||||
import bulkActions from './bulkActions.json';
|
||||
import calls from './calls.json';
|
||||
import campaign from './campaign.json';
|
||||
import cannedMgmt from './cannedMgmt.json';
|
||||
import chatlist from './chatlist.json';
|
||||
@@ -51,6 +52,7 @@ export default {
|
||||
...auditLogs,
|
||||
...automation,
|
||||
...bulkActions,
|
||||
...calls,
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"COMPANIES": "Companies",
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"CAPTAIN": "Captain",
|
||||
"CALLS": "Calls",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_OVERVIEW": "Overview",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useCallHistoryStore } from 'dashboard/stores/callHistory';
|
||||
|
||||
import CallListItem from 'dashboard/components-next/Calls/CallListItem.vue';
|
||||
import CallsEmptyState from 'dashboard/components-next/Calls/CallsEmptyState.vue';
|
||||
import CallsFilterBar from 'dashboard/components-next/Calls/CallsFilterBar.vue';
|
||||
import { CALL_ACTIVITY_PARAMS } from 'dashboard/components-next/Calls/constants';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const RESULTS_PER_PAGE = 25;
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const callHistoryStore = useCallHistoryStore();
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
// CallFinder scopes non-admins to their own accepted calls, so the assignee
|
||||
// filter is only meaningful for admins; everyone else defaults to themselves.
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const voiceInboxes = computed(() => inboxes.value.filter(isVoiceCallEnabled));
|
||||
|
||||
const isVoiceEnabled = computed(
|
||||
() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CHANNEL_VOICE
|
||||
) && voiceInboxes.value.length > 0
|
||||
);
|
||||
|
||||
const calls = computed(() => callHistoryStore.records);
|
||||
const meta = computed(() => callHistoryStore.meta);
|
||||
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
|
||||
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
|
||||
// Filters are seeded from the URL so a shared link restores the same view.
|
||||
const activity = ref(
|
||||
CALL_ACTIVITY_PARAMS[route.query.activity] ? route.query.activity : null
|
||||
);
|
||||
|
||||
const assigneeId = ref(
|
||||
isAdmin.value ? Number(route.query.assignee_id) || null : currentUserId.value
|
||||
);
|
||||
const inboxId = ref(Number(route.query.inbox_id) || null);
|
||||
const currentPage = ref(Number(route.query.page) || 1);
|
||||
|
||||
const syncFiltersToUrl = () => {
|
||||
router.replace({
|
||||
query: {
|
||||
...(activity.value && { activity: activity.value }),
|
||||
...(isAdmin.value &&
|
||||
assigneeId.value && { assignee_id: assigneeId.value }),
|
||||
...(inboxId.value && { inbox_id: inboxId.value }),
|
||||
...(currentPage.value > 1 && { page: currentPage.value }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCalls = async () => {
|
||||
syncFiltersToUrl();
|
||||
try {
|
||||
await callHistoryStore.fetchCalls({
|
||||
page: currentPage.value,
|
||||
...(CALL_ACTIVITY_PARAMS[activity.value] || {}),
|
||||
...(assigneeId.value ? { agent_id: assigneeId.value } : {}),
|
||||
...(inboxId.value ? { inbox_id: inboxId.value } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
watch([activity, assigneeId, inboxId], () => {
|
||||
currentPage.value = 1;
|
||||
fetchCalls();
|
||||
});
|
||||
|
||||
const onPageChange = page => {
|
||||
currentPage.value = page;
|
||||
fetchCalls();
|
||||
};
|
||||
|
||||
// inboxes/get flips isFetching true synchronously, so the spinner shows on the
|
||||
// first render and the setup CTA never flashes; hit the calls endpoint only
|
||||
// once inboxes confirm voice is on.
|
||||
store.dispatch('inboxes/get').then(() => {
|
||||
if (!isVoiceEnabled.value) return;
|
||||
// Only admins see the assignee filter, so only they need the agent list.
|
||||
if (isAdmin.value) store.dispatch('agents/get');
|
||||
fetchCalls();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="inboxesUiFlags.isFetching"
|
||||
class="flex items-center justify-center w-full h-full bg-n-surface-1"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<CallsEmptyState v-else-if="!isVoiceEnabled" />
|
||||
<section
|
||||
v-else
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||
<div class="w-full">
|
||||
<h1 class="text-xl font-medium text-n-slate-12">
|
||||
{{ t('CALLS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full">
|
||||
<div v-if="isFetching" class="flex items-center justify-center py-16">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!calls.length"
|
||||
class="flex items-center justify-center py-16"
|
||||
>
|
||||
<span class="text-base text-n-slate-11">
|
||||
{{ t('CALLS_PAGE.EMPTY_STATE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<CallListItem v-for="call in calls" :key="call.id" :call="call" />
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="calls.length" class="sticky bottom-0 shrink-0">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="meta.count"
|
||||
:items-per-page="RESULTS_PER_PAGE"
|
||||
@update:current-page="onPageChange"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
ROLES,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CallsIndex from './pages/CallsIndex.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/calls'),
|
||||
name: 'calls_dashboard_index',
|
||||
component: CallsIndex,
|
||||
meta: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
import { routes as callRoutes } from './calls/routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as companyRoutes } from './companies/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
@@ -25,6 +26,7 @@ export default {
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
...callRoutes,
|
||||
...contactRoutes,
|
||||
...companyRoutes,
|
||||
...searchRoutes,
|
||||
|
||||
@@ -49,9 +49,10 @@ const hasDuplicateInstagramInbox = computed(() => {
|
||||
});
|
||||
|
||||
const shouldShowWhatsAppWebhookDetails = computed(() => {
|
||||
const source = currentInbox.value.provider_config?.source;
|
||||
return (
|
||||
isAWhatsAppCloudChannel.value &&
|
||||
currentInbox.value.provider_config?.source !== 'embedded_signup'
|
||||
!['embedded_signup', 'manual_setup_v2'].includes(source)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const items = computed(() => {
|
||||
:global-config="globalConfig"
|
||||
:items="items"
|
||||
/>
|
||||
<div class="col-span-6 flex flex-col overflow-y-auto">
|
||||
<div class="col-span-6 flex min-h-0 flex-col overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n, I18nT } from 'vue-i18n';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappManualSetup from './WhatsappManualSetup.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
@@ -79,14 +80,21 @@ const shouldShowCloudWhatsapp = provider => {
|
||||
);
|
||||
};
|
||||
|
||||
const isManualSetup = computed(
|
||||
() =>
|
||||
showConfiguration.value && shouldShowCloudWhatsapp(selectedProvider.value)
|
||||
);
|
||||
|
||||
const handleManualLinkClick = () => {
|
||||
selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto col-span-6 p-6 w-full h-full">
|
||||
<div v-if="showProviderSelection">
|
||||
<div class="col-span-6 w-full h-full min-h-0 overflow-y-auto p-6">
|
||||
<WhatsappManualSetup v-if="isManualSetup" />
|
||||
|
||||
<div v-else-if="showProviderSelection">
|
||||
<div class="mb-10 text-left">
|
||||
<h1 class="mb-2 text-lg font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
|
||||
@@ -137,9 +145,6 @@ const handleManualLinkClick = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show manual setup -->
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
|
||||
<!-- Other providers -->
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
|
||||
+826
@@ -0,0 +1,826 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { I18nT, useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import WhatsappChannelAPI from 'dashboard/api/channel/whatsappChannel';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
const POLL_INTERVAL = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
const META_APPS_URL = 'https://developers.facebook.com/apps/';
|
||||
const META_BUSINESS_SETTINGS_URL =
|
||||
'https://business.facebook.com/settings/system-users/';
|
||||
const CREATE_APP_VIDEO_URL =
|
||||
'/videos/whatsapp/manual-setup/create-meta-app.mp4';
|
||||
const CREATE_APP_VIDEO_POSTER_URL =
|
||||
'/videos/whatsapp/manual-setup/create-meta-app-poster.jpg';
|
||||
const ADD_NUMBER_VIDEO_URL =
|
||||
'/videos/whatsapp/manual-setup/add-phone-number.mp4';
|
||||
const ADD_NUMBER_VIDEO_POSTER_URL =
|
||||
'/videos/whatsapp/manual-setup/add-phone-number-poster.jpg';
|
||||
const GENERATE_TOKEN_VIDEO_URL =
|
||||
'/videos/whatsapp/manual-setup/generate-access-token.mp4';
|
||||
const GENERATE_TOKEN_VIDEO_POSTER_URL =
|
||||
'/videos/whatsapp/manual-setup/generate-access-token-poster.jpg';
|
||||
|
||||
const currentStep = ref(1);
|
||||
const isLoading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const inboxId = ref(null);
|
||||
const pollTimer = ref(null);
|
||||
const showAccessToken = ref(false);
|
||||
const setupRoot = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
wabaId: '',
|
||||
phoneNumberId: '',
|
||||
accessToken: '',
|
||||
inboxName: '',
|
||||
});
|
||||
|
||||
const preview = ref(null);
|
||||
const connection = reactive({
|
||||
numberAccess: false,
|
||||
templateAccess: false,
|
||||
webhookSetup: false,
|
||||
callbackVerified: false,
|
||||
callbackConfigured: false,
|
||||
callbackUrl: '',
|
||||
subscriptionVerified: false,
|
||||
});
|
||||
|
||||
const idsComplete = computed(
|
||||
() => form.wabaId.trim() && form.phoneNumberId.trim()
|
||||
);
|
||||
|
||||
const detailsComplete = computed(
|
||||
() => idsComplete.value && form.accessToken.trim()
|
||||
);
|
||||
|
||||
const connectionReady = computed(
|
||||
() =>
|
||||
connection.numberAccess &&
|
||||
connection.templateAccess &&
|
||||
connection.webhookSetup &&
|
||||
connection.callbackVerified &&
|
||||
connection.callbackConfigured &&
|
||||
connection.subscriptionVerified
|
||||
);
|
||||
|
||||
const progressSteps = computed(() =>
|
||||
Array.from({ length: TOTAL_STEPS }, (_, index) => index + 1)
|
||||
);
|
||||
|
||||
const appInstructions = computed(() => [
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_2'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_3'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_4'),
|
||||
]);
|
||||
|
||||
const numberInstructions = computed(() => [
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_1'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_2'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_3'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_4'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_5'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.ITEM_6'),
|
||||
]);
|
||||
|
||||
const tokenInstructions = computed(() => [
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_1'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_2'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_3'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_4'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_5'),
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.ITEM_6'),
|
||||
]);
|
||||
|
||||
const statusRows = computed(() => [
|
||||
{
|
||||
key: 'number',
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.NUMBER_ACCESS'),
|
||||
complete: connection.numberAccess,
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.TEMPLATE_ACCESS'),
|
||||
complete: connection.templateAccess,
|
||||
},
|
||||
{
|
||||
key: 'callback',
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.CALLBACK'),
|
||||
complete:
|
||||
connection.webhookSetup &&
|
||||
connection.callbackConfigured &&
|
||||
connection.callbackVerified,
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.SUBSCRIPTION'),
|
||||
complete: connection.subscriptionVerified,
|
||||
},
|
||||
]);
|
||||
|
||||
const apiErrorMessage = error => {
|
||||
const message = error.response?.data?.message || '';
|
||||
if (/invalid oauth access token|cannot parse access token/i.test(message)) {
|
||||
return t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.INVALID_TOKEN');
|
||||
}
|
||||
|
||||
return message || t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.GENERIC');
|
||||
};
|
||||
|
||||
const setStep = step => {
|
||||
errorMessage.value = '';
|
||||
currentStep.value = step;
|
||||
nextTick(() => setupRoot.value?.scrollIntoView({ block: 'start' }));
|
||||
};
|
||||
|
||||
const returnToProviders = () => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: {},
|
||||
});
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (currentStep.value > 1) {
|
||||
setStep(currentStep.value - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
returnToProviders();
|
||||
};
|
||||
|
||||
const continueFromIds = () => {
|
||||
if (!idsComplete.value) {
|
||||
errorMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.IDS_REQUIRED'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
const verifyDetails = async () => {
|
||||
if (!detailsComplete.value) {
|
||||
errorMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ERRORS.REQUIRED'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { data } = await WhatsappChannelAPI.previewManualSetup({
|
||||
waba_id: form.wabaId.trim(),
|
||||
phone_number_id: form.phoneNumberId.trim(),
|
||||
access_token: form.accessToken.trim(),
|
||||
});
|
||||
preview.value = data;
|
||||
form.inboxName = data.suggested_inbox_name;
|
||||
setStep(4);
|
||||
} catch (error) {
|
||||
errorMessage.value = apiErrorMessage(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyWebhookStatus = status => {
|
||||
connection.callbackVerified = Boolean(status.callback_verified);
|
||||
connection.callbackConfigured = Boolean(status.callback_configured);
|
||||
connection.callbackUrl = status.callback_url || '';
|
||||
connection.subscriptionVerified = Boolean(status.subscription_verified);
|
||||
};
|
||||
|
||||
const copyWebhookUrl = async () => {
|
||||
await copyTextToClipboard(connection.callbackUrl);
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COPY_SUCCESS'));
|
||||
};
|
||||
|
||||
const refreshWebhookStatus = async ({ showError = true } = {}) => {
|
||||
if (!inboxId.value) return;
|
||||
|
||||
try {
|
||||
const { data } = await WhatsappChannelAPI.getManualWebhookStatus(
|
||||
inboxId.value
|
||||
);
|
||||
applyWebhookStatus(data);
|
||||
} catch (error) {
|
||||
if (showError) errorMessage.value = apiErrorMessage(error);
|
||||
}
|
||||
};
|
||||
|
||||
const pollWebhookStatus = async attempt => {
|
||||
await refreshWebhookStatus({ showError: false });
|
||||
if (connectionReady.value || attempt >= MAX_POLL_ATTEMPTS) return;
|
||||
|
||||
pollTimer.value = window.setTimeout(
|
||||
() => pollWebhookStatus(attempt + 1),
|
||||
POLL_INTERVAL
|
||||
);
|
||||
};
|
||||
|
||||
const connectNumber = async () => {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { data } = await WhatsappChannelAPI.connectManualSetup({
|
||||
waba_id: form.wabaId.trim(),
|
||||
phone_number_id: form.phoneNumberId.trim(),
|
||||
access_token: form.accessToken.trim(),
|
||||
inbox_name: form.inboxName.trim(),
|
||||
});
|
||||
inboxId.value = data.id;
|
||||
connection.numberAccess = Boolean(data.number_access);
|
||||
connection.templateAccess = Boolean(data.template_access);
|
||||
connection.webhookSetup = Boolean(data.webhook_setup);
|
||||
if (data.webhook_error) errorMessage.value = data.webhook_error;
|
||||
currentStep.value = 5;
|
||||
await pollWebhookStatus(1);
|
||||
} catch (error) {
|
||||
errorMessage.value = apiErrorMessage(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryWebhookSetup = async () => {
|
||||
isLoading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const { data } = await WhatsappChannelAPI.setupManualWebhook(inboxId.value);
|
||||
connection.webhookSetup = true;
|
||||
applyWebhookStatus(data);
|
||||
if (!connectionReady.value) await pollWebhookStatus(1);
|
||||
} catch (error) {
|
||||
errorMessage.value = apiErrorMessage(error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const continueToAgents = async () => {
|
||||
await store.dispatch('inboxes/get');
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: { page: 'new', inbox_id: inboxId.value },
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer.value) window.clearTimeout(pollTimer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="setupRoot"
|
||||
class="mx-auto flex w-full max-w-6xl flex-col gap-4 py-2"
|
||||
>
|
||||
<div class="px-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.HEADER.TITLE') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.HEADER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-n-weak bg-n-background p-5 shadow-sm sm:p-6"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<div class="grid w-40 grid-cols-5 gap-1.5">
|
||||
<div
|
||||
v-for="step in progressSteps"
|
||||
:key="step"
|
||||
class="h-1.5 rounded-full"
|
||||
:class="step <= currentStep ? 'bg-n-brand' : 'bg-n-alpha-3'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage && currentStep !== 3"
|
||||
class="mt-6 rounded-lg border border-n-ruby-5 bg-n-ruby-3 px-4 py-3 text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<section v-if="currentStep === 1" class="mt-3 grid gap-5 lg:grid-cols-2">
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
|
||||
>
|
||||
<li class="pl-2">
|
||||
<I18nT
|
||||
keypath="INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.ITEM_1"
|
||||
tag="span"
|
||||
>
|
||||
<template #metaDevelopers>
|
||||
<a
|
||||
:href="META_APPS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-n-brand hover:underline"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.META_DEVELOPERS'
|
||||
)
|
||||
}}
|
||||
</a>
|
||||
</template>
|
||||
</I18nT>
|
||||
</li>
|
||||
<li
|
||||
v-for="instruction in appInstructions"
|
||||
:key="instruction"
|
||||
class="pl-2"
|
||||
>
|
||||
{{ instruction }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<figure
|
||||
class="self-start overflow-hidden rounded-xl border border-n-weak"
|
||||
>
|
||||
<video
|
||||
class="block w-full bg-n-solid-1"
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="CREATE_APP_VIDEO_POSTER_URL"
|
||||
:aria-label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_TITLE')
|
||||
"
|
||||
>
|
||||
<source :src="CREATE_APP_VIDEO_URL" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.APP.VIDEO_TITLE') }}
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
|
||||
>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.APP_READY')
|
||||
"
|
||||
trailing-icon
|
||||
icon="i-lucide-arrow-right"
|
||||
@click="setStep(2)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="currentStep === 2"
|
||||
class="mt-3 grid gap-5 lg:grid-cols-2"
|
||||
>
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
|
||||
>
|
||||
<li
|
||||
v-for="instruction in numberInstructions"
|
||||
:key="instruction"
|
||||
class="pl-2"
|
||||
>
|
||||
{{ instruction }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<figure class="overflow-hidden rounded-xl border border-n-weak">
|
||||
<video
|
||||
class="block w-full bg-n-solid-1"
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="ADD_NUMBER_VIDEO_POSTER_URL"
|
||||
:aria-label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_TITLE')
|
||||
"
|
||||
>
|
||||
<source :src="ADD_NUMBER_VIDEO_URL" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.NUMBER.VIDEO_TITLE')
|
||||
}}
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<a
|
||||
:href="META_APPS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex w-fit items-center gap-2 text-sm font-medium text-n-brand hover:underline"
|
||||
>
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.OPEN_META_APPS')
|
||||
}}
|
||||
<Icon icon="i-lucide-external-link" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-5 rounded-xl border border-n-weak p-5 lg:col-span-2 lg:grid-cols-2"
|
||||
>
|
||||
<Input
|
||||
v-model="form.phoneNumberId"
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.PHONE_ID_LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.PHONE_ID_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-model="form.wabaId"
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.WABA_LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.WABA_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
|
||||
>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.NEXT')"
|
||||
trailing-icon
|
||||
icon="i-lucide-arrow-right"
|
||||
@click="continueFromIds"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="currentStep === 3"
|
||||
class="mt-3 grid gap-5 lg:grid-cols-2"
|
||||
>
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
class="ml-5 list-decimal space-y-3 text-body-main text-n-slate-11 marker:font-medium marker:text-n-slate-12"
|
||||
>
|
||||
<li
|
||||
v-for="instruction in tokenInstructions"
|
||||
:key="instruction"
|
||||
class="pl-2"
|
||||
>
|
||||
{{ instruction }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<figure class="overflow-hidden rounded-xl border border-n-weak">
|
||||
<video
|
||||
class="block w-full bg-n-solid-1"
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="GENERATE_TOKEN_VIDEO_POSTER_URL"
|
||||
:aria-label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_TITLE')
|
||||
"
|
||||
>
|
||||
<source :src="GENERATE_TOKEN_VIDEO_URL" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption class="border-t border-n-weak bg-n-alpha-1 px-4 py-3">
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.VIDEO_TITLE')
|
||||
}}
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<a
|
||||
:href="META_BUSINESS_SETTINGS_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex w-fit items-center gap-2 text-sm font-medium text-n-brand hover:underline"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.OPEN_BUSINESS_SETTINGS'
|
||||
)
|
||||
}}
|
||||
<Icon icon="i-lucide-external-link" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-n-weak p-5 lg:col-span-2">
|
||||
<div class="grid items-start gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<Input
|
||||
v-model="form.accessToken"
|
||||
:type="showAccessToken ? 'text' : 'password'"
|
||||
autocomplete="off"
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.TOKEN_LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.DETAILS.TOKEN_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:message="errorMessage"
|
||||
message-type="error"
|
||||
@update:model-value="errorMessage = ''"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
showAccessToken
|
||||
? $t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.HIDE_TOKEN'
|
||||
)
|
||||
: $t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.SHOW_TOKEN'
|
||||
)
|
||||
"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
class="sm:mt-7"
|
||||
@click="showAccessToken = !showAccessToken"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="!errorMessage"
|
||||
class="mt-3 flex items-start gap-2 text-sm text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-triangle-alert" class="mt-0.5 shrink-0" />
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.TOKEN.WARNING') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-n-weak pt-5 lg:col-span-2"
|
||||
>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.VERIFY_DETAILS')
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="verifyDetails"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentStep === 4" class="mt-3 flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-n-teal-5 bg-n-teal-3 px-4 py-3 text-sm text-n-teal-11"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<Icon icon="i-lucide-badge-check" />
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.VERIFIED') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="divide-y divide-n-weak rounded-xl border border-n-weak px-5">
|
||||
<div class="grid grid-cols-2 gap-4 py-4">
|
||||
<dt class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.BUSINESS_NAME')
|
||||
}}
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-n-slate-12">
|
||||
{{ preview.verified_name || preview.display_phone_number }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 py-4">
|
||||
<dt class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.PHONE_NUMBER')
|
||||
}}
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-n-slate-12">
|
||||
{{ preview.display_phone_number }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 py-4">
|
||||
<dt class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.PHONE_ID') }}
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-n-slate-12">
|
||||
{{ preview.phone_number_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 py-4">
|
||||
<dt class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.WABA_ID') }}
|
||||
</dt>
|
||||
<dd class="text-sm font-medium text-n-slate-12">
|
||||
{{ preview.waba_id }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Input
|
||||
v-model="form.inboxName"
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.INBOX_NAME')"
|
||||
:message="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.REVIEW.INBOX_NAME_HELP')
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-n-weak pt-5"
|
||||
>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.BACK')"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.CONNECT')"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading || !form.inboxName.trim()"
|
||||
@click="connectNumber"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="mt-3 flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="connection.callbackUrl"
|
||||
class="rounded-xl border border-n-weak p-5"
|
||||
>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.WEBHOOK_URL') }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<code
|
||||
class="min-w-0 flex-1 break-all rounded-lg bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12"
|
||||
>
|
||||
{{ connection.callbackUrl }}
|
||||
</code>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COPY')"
|
||||
icon="i-lucide-copy"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="copyWebhookUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col divide-y divide-n-weak rounded-xl border border-n-weak px-5"
|
||||
>
|
||||
<div
|
||||
v-for="status in statusRows"
|
||||
:key="status.key"
|
||||
class="flex items-center justify-between py-4"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ status.label }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="status.complete ? 'text-n-teal-11' : 'text-n-amber-11'"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
status.complete ? 'i-lucide-check-circle' : 'i-lucide-clock-3'
|
||||
"
|
||||
/>
|
||||
{{
|
||||
status.complete
|
||||
? $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.COMPLETE')
|
||||
: $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.PENDING')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="connectionReady"
|
||||
class="rounded-lg bg-n-teal-3 px-4 py-3 text-sm text-n-teal-11"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.VERIFY.SUCCESS') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-n-weak pt-5"
|
||||
>
|
||||
<Button
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.RETRY_WEBHOOK')
|
||||
"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="retryWebhookSetup"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.MANUAL_SETUP.ACTIONS.CONTINUE')"
|
||||
trailing-icon
|
||||
icon="i-lucide-arrow-right"
|
||||
:disabled="isLoading"
|
||||
@click="continueToAgents"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import CallsAPI from 'dashboard/api/calls';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useCallHistoryStore = defineStore('callHistory', {
|
||||
state: () => ({
|
||||
records: [],
|
||||
meta: { count: 0, currentPage: 1, totalPages: 0 },
|
||||
uiFlags: { isFetching: false },
|
||||
fetchRequestToken: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchCalls(params = {}) {
|
||||
this.uiFlags.isFetching = true;
|
||||
this.fetchRequestToken += 1;
|
||||
const requestToken = this.fetchRequestToken;
|
||||
try {
|
||||
const { data } = await CallsAPI.get(params);
|
||||
// A newer fetch (filter/page change) superseded this one; drop the result.
|
||||
if (this.fetchRequestToken !== requestToken) return this.records;
|
||||
this.records = camelcaseKeys(data.payload, { deep: true });
|
||||
this.meta = camelcaseKeys(data.meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
// Don't surface errors from a fetch that a newer request already replaced.
|
||||
if (this.fetchRequestToken !== requestToken) return this.records;
|
||||
// Drop the previous results so stale rows aren't shown under the new view.
|
||||
this.records = [];
|
||||
this.meta = { count: 0, currentPage: 1, totalPages: 0 };
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.fetchRequestToken === requestToken) {
|
||||
this.uiFlags.isFetching = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CallsAPI from 'dashboard/api/calls';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { useCallHistoryStore } from '../callHistory';
|
||||
|
||||
vi.mock('dashboard/api/calls', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(error => error),
|
||||
}));
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(res => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const buildResponse = (payload, meta) => ({ data: { payload, meta } });
|
||||
|
||||
describe('callHistory store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches calls and stores camelized records and meta', async () => {
|
||||
CallsAPI.get.mockResolvedValue(
|
||||
buildResponse(
|
||||
[{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }],
|
||||
{ count: 44, current_page: 1, total_pages: 2 }
|
||||
)
|
||||
);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
await store.fetchCalls({ page: 1, status: 'no-answer' });
|
||||
|
||||
expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' });
|
||||
expect(store.records).toEqual([
|
||||
{ id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } },
|
||||
]);
|
||||
expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 });
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a superseded response that resolves after the latest one', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
CallsAPI.get
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
const staleFetch = store.fetchCalls({ page: 1 });
|
||||
const currentFetch = store.fetchCalls({ page: 2 });
|
||||
|
||||
secondRequest.resolve(
|
||||
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
|
||||
);
|
||||
await currentFetch;
|
||||
|
||||
firstRequest.resolve(
|
||||
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
|
||||
);
|
||||
await staleFetch;
|
||||
|
||||
expect(store.records).toEqual([{ id: 2 }]);
|
||||
expect(store.meta.count).toBe(1);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps fetching state when a superseded response resolves first', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
CallsAPI.get
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
const staleFetch = store.fetchCalls({ page: 1 });
|
||||
const currentFetch = store.fetchCalls({ page: 2 });
|
||||
|
||||
firstRequest.resolve(
|
||||
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
|
||||
);
|
||||
await staleFetch;
|
||||
|
||||
expect(store.records).toEqual([]);
|
||||
expect(store.uiFlags.isFetching).toBe(true);
|
||||
|
||||
secondRequest.resolve(
|
||||
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
|
||||
);
|
||||
await currentFetch;
|
||||
|
||||
expect(store.records).toEqual([{ id: 2 }]);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces the error and resets fetching state on failure', async () => {
|
||||
const error = new Error('Request failed');
|
||||
CallsAPI.get.mockRejectedValue(error);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
await store.fetchCalls();
|
||||
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(store.records).toEqual([]);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useCompaniesStore } from './companies';
|
||||
import { useCompaniesStore } from '../companies';
|
||||
|
||||
vi.mock('dashboard/api/companies', () => ({
|
||||
default: {
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
messageStamp,
|
||||
messageTimestamp,
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
shortTimestamp,
|
||||
dynamicTime,
|
||||
getDayDifferenceFromNow,
|
||||
hasOneDayPassed,
|
||||
messageStamp,
|
||||
messageTimestamp,
|
||||
relativeDayTimestamp,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -37,6 +38,33 @@ describe('#messageTimestamp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#relativeDayTimestamp', () => {
|
||||
// System time is mocked to May 5, 2023 00:00 UTC.
|
||||
const toUnix = date => Math.floor(date / 1000);
|
||||
|
||||
it('returns the time for timestamps from today', () => {
|
||||
const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0));
|
||||
expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM');
|
||||
});
|
||||
|
||||
it('returns the supplied label for timestamps from yesterday', () => {
|
||||
const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0));
|
||||
expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday');
|
||||
});
|
||||
|
||||
it('returns a day and month for older timestamps in the current year', () => {
|
||||
const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0));
|
||||
expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual(
|
||||
'Feb 10'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a full date for timestamps from a previous year', () => {
|
||||
const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0));
|
||||
expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#dynamicTime', () => {
|
||||
it('returns correct value', () => {
|
||||
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
format,
|
||||
isSameYear,
|
||||
isThisYear,
|
||||
isToday,
|
||||
isYesterday,
|
||||
fromUnixTime,
|
||||
formatDistanceToNow,
|
||||
differenceInDays,
|
||||
@@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
|
||||
return messageDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp relative to today: the time for today, a caller-
|
||||
* supplied label for yesterday, and a date otherwise. The yesterday label is
|
||||
* passed in so the caller keeps ownership of translation.
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @param {string} yesterdayLabel - Localized label shown for yesterday.
|
||||
* @returns {string} Formatted timestamp string.
|
||||
*/
|
||||
export const relativeDayTimestamp = (time, yesterdayLabel) => {
|
||||
const date = fromUnixTime(time);
|
||||
if (isToday(date)) return format(date, 'h:mm a');
|
||||
if (isYesterday(date)) return yesterdayLabel;
|
||||
if (isThisYear(date)) return format(date, 'MMM d');
|
||||
return format(date, 'MMM d, yyyy');
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
|
||||
* @param {number} time - Unix timestamp.
|
||||
|
||||
@@ -160,8 +160,9 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
|
||||
def should_auto_setup_webhooks?
|
||||
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
|
||||
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
|
||||
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
|
||||
# Embedded signup and Manual V2 run webhook setup explicitly so their API
|
||||
# responses can reflect the real result instead of swallowing callback errors.
|
||||
explicitly_configured_sources = %w[embedded_signup manual_setup_v2]
|
||||
provider == 'whatsapp_cloud' && explicitly_configured_sources.exclude?(provider_config['source'])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,54 @@ class Whatsapp::FacebookApiClient
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def fetch_all_phone_numbers(waba_id)
|
||||
phone_numbers = []
|
||||
after_cursor = nil
|
||||
|
||||
loop do
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
headers: request_headers,
|
||||
query: after_cursor.present? ? { after: after_cursor } : {}
|
||||
)
|
||||
data = handle_response(response, 'WABA phone numbers fetch failed')
|
||||
phone_numbers.concat(data['data'] || [])
|
||||
after_cursor = data.dig('paging', 'cursors', 'after') if data.dig('paging', 'next').present?
|
||||
break if after_cursor.blank?
|
||||
end
|
||||
|
||||
phone_numbers
|
||||
end
|
||||
|
||||
def fetch_message_templates(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/message_templates",
|
||||
headers: request_headers,
|
||||
query: { limit: 1 }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA message templates fetch failed')
|
||||
end
|
||||
|
||||
def fetch_subscribed_apps(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA webhook subscription fetch failed')
|
||||
end
|
||||
|
||||
def fetch_phone_number(phone_number_id, fields: nil)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}",
|
||||
headers: request_headers,
|
||||
query: fields.present? ? { fields: fields } : {}
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone number fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::ManualSetupService
|
||||
attr_reader :channel, :webhook_error
|
||||
|
||||
def initialize(account:, waba_id:, phone_number_id:, access_token:, inbox_name: nil)
|
||||
@account = account
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@inbox_name = inbox_name
|
||||
end
|
||||
|
||||
def perform
|
||||
preview = validate_setup
|
||||
create_channel_and_inbox(preview)
|
||||
setup_webhook
|
||||
self
|
||||
end
|
||||
|
||||
def webhook_setup?
|
||||
webhook_error.blank?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_setup
|
||||
Whatsapp::ManualSetupValidationService.new(
|
||||
waba_id: @waba_id,
|
||||
phone_number_id: @phone_number_id,
|
||||
access_token: @access_token
|
||||
).perform
|
||||
end
|
||||
|
||||
def create_channel_and_inbox(preview)
|
||||
ActiveRecord::Base.transaction do
|
||||
@channel = @account.whatsapp_channels.create!(
|
||||
phone_number: preview[:display_phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
api_key: @access_token,
|
||||
phone_number_id: preview[:phone_number_id],
|
||||
business_account_id: preview[:waba_id],
|
||||
source: 'manual_setup_v2'
|
||||
}
|
||||
)
|
||||
@account.inboxes.create!(
|
||||
name: @inbox_name.to_s.strip.presence || preview[:suggested_inbox_name],
|
||||
channel: @channel
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
Whatsapp::WebhookSetupService.new(@channel, @waba_id, @access_token).register_callback
|
||||
rescue StandardError => e
|
||||
@webhook_error = e.message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
class Whatsapp::ManualSetupValidationService
|
||||
LOG_PREFIX = '[WHATSAPP MANUAL SETUP]'.freeze
|
||||
|
||||
def initialize(waba_id:, phone_number_id:, access_token:)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
Rails.logger.info "#{LOG_PREFIX} Validation started waba_id=#{@waba_id} phone_number_id=#{@phone_number_id}"
|
||||
|
||||
phone_data = find_phone_data!
|
||||
verify_uniqueness!(phone_data)
|
||||
verify_template_access!
|
||||
|
||||
build_preview(phone_data)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Phone Number ID is required' if @phone_number_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_phone_data!
|
||||
phone_numbers = @api_client.fetch_all_phone_numbers(@waba_id)
|
||||
returned_phone_number_ids = phone_numbers.filter_map { |phone| phone['id'] }.join(',')
|
||||
Rails.logger.info "#{LOG_PREFIX} Meta returned #{phone_numbers.size} phone number(s) for waba_id=#{@waba_id} " \
|
||||
"phone_number_ids=#{returned_phone_number_ids}"
|
||||
|
||||
phone_data = phone_numbers.find { |phone| phone['id'].to_s == @phone_number_id.to_s }
|
||||
raise ArgumentError, 'This Phone Number ID does not belong to the WABA ID you entered.' if phone_data.blank?
|
||||
|
||||
Rails.logger.info "#{LOG_PREFIX} Matched phone_number_id=#{@phone_number_id} fields=#{phone_data.keys.sort.join(',')} " \
|
||||
"code_verification_status=#{phone_data['code_verification_status'].inspect} " \
|
||||
"name_status=#{phone_data['name_status'].inspect}"
|
||||
|
||||
phone_data
|
||||
end
|
||||
|
||||
def verify_uniqueness!(phone_data)
|
||||
phone_number = normalized_phone_number(phone_data['display_phone_number'])
|
||||
raise ArgumentError, 'This WhatsApp number is already connected to another inbox.' if Channel::Whatsapp.exists?(phone_number: phone_number)
|
||||
|
||||
duplicate_phone_id = Channel::Whatsapp.exists?(["provider_config->>'phone_number_id' = ?", @phone_number_id.to_s])
|
||||
raise ArgumentError, 'This Phone Number ID is already used by another WhatsApp inbox.' if duplicate_phone_id
|
||||
end
|
||||
|
||||
def verify_template_access!
|
||||
@api_client.fetch_message_templates(@waba_id)
|
||||
rescue StandardError
|
||||
raise ArgumentError,
|
||||
'The token can access the number but cannot access message templates. Generate a token with whatsapp_business_management permission.'
|
||||
end
|
||||
|
||||
def build_preview(phone_data)
|
||||
phone_number = normalized_phone_number(phone_data['display_phone_number'])
|
||||
verified_name = phone_data['verified_name'].presence
|
||||
|
||||
{
|
||||
verified_name: verified_name,
|
||||
display_phone_number: phone_number,
|
||||
phone_number_id: phone_data['id'].to_s,
|
||||
waba_id: @waba_id.to_s,
|
||||
template_access: true,
|
||||
suggested_inbox_name: "#{verified_name || phone_number} WhatsApp"
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
digits = phone_number.to_s.gsub(/[^\d]/, '')
|
||||
"+#{digits}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Whatsapp::ManualWebhookStatusService
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
@api_client = Whatsapp::FacebookApiClient.new(channel.provider_config['api_key'])
|
||||
end
|
||||
|
||||
def perform
|
||||
callback_configured = callback_configured?
|
||||
|
||||
{
|
||||
callback_verified: callback_configured,
|
||||
callback_configured: callback_configured,
|
||||
callback_url: callback_url,
|
||||
subscription_verified: subscription_verified?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def callback_configured?
|
||||
phone_number = @api_client.fetch_phone_number(
|
||||
@channel.provider_config['phone_number_id'],
|
||||
fields: 'webhook_configuration'
|
||||
)
|
||||
webhook_configuration = phone_number.fetch('webhook_configuration', {})
|
||||
|
||||
%w[override_callback_uri phone_number whatsapp_business_account application].any? do |key|
|
||||
webhook_configuration[key] == callback_url
|
||||
end
|
||||
end
|
||||
|
||||
def subscription_verified?
|
||||
@api_client.fetch_subscribed_apps(@channel.provider_config['business_account_id']).fetch('data', []).present?
|
||||
end
|
||||
|
||||
def callback_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{@channel.phone_number}"
|
||||
end
|
||||
end
|
||||
@@ -357,6 +357,10 @@ Rails.application.routes.draw do
|
||||
|
||||
namespace :whatsapp do
|
||||
resource :authorization, only: [:create]
|
||||
post 'manual/preview', to: 'manual_setup#preview'
|
||||
post 'manual/connect', to: 'manual_setup#connect'
|
||||
get 'manual/:inbox_id/webhook_status', to: 'manual_setup#webhook_status'
|
||||
post 'manual/:inbox_id/setup_webhook', to: 'manual_setup#setup_webhook'
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
Reference in New Issue
Block a user