Merge branch 'develop' into fix/mexico-whatsapp-phone-normalization

This commit is contained in:
Muhsin Keloth
2026-05-06 14:12:33 +04:00
committed by GitHub
279 changed files with 10323 additions and 1871 deletions
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -11,6 +11,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deployment_check:
name: Check Deployment
+3
View File
@@ -8,6 +8,9 @@ on:
branches:
- develop
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
+29
View File
@@ -0,0 +1,29 @@
name: Sync GHSA advisories to Linear
on:
schedule:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
@@ -10,6 +10,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
+3
View File
@@ -14,6 +14,9 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
@@ -3,6 +3,10 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
+3
View File
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
@@ -18,6 +18,9 @@ on:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs:
build:
strategy:
+3
View File
@@ -10,6 +10,9 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
+3
View File
@@ -7,6 +7,9 @@ on:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
test-build:
strategy:
+1
View File
@@ -22,6 +22,7 @@ gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+7 -5
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -349,6 +349,7 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0)
grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1)
gli (2.22.2)
ostruct
@@ -676,14 +677,14 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (7.0.5)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack (3.2.6)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -698,7 +699,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -1075,6 +1076,7 @@ DEPENDENCIES
fcm
flag_shih_tzu
foreman
gemoji
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -2,6 +2,10 @@
# This concern handles the token verification step.
module MetaTokenVerifyConcern
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze
META_SIGNATURE_PREFIX = 'sha256='.freeze
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
@@ -14,6 +18,53 @@ module MetaTokenVerifyConcern
private
def verify_meta_signature!
return unless meta_signature_verification_required?
return if valid_meta_signature?
head :unauthorized
end
def valid_meta_signature?
signature = request.headers[META_SIGNATURE_HEADER]
return false unless signature&.start_with?(META_SIGNATURE_PREFIX)
meta_app_secrets.any? do |secret|
next false if secret.blank?
expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}"
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
end
end
def meta_request_body
@meta_request_body ||= request.raw_post
end
def meta_app_secrets
raise 'Overwrite this method in your controller'
end
def meta_signature_verification_required?
true
end
def channel_meta_app_secrets(channel)
return [] if channel.blank?
secrets = []
secrets << channel.app_secret if channel.respond_to?(:app_secret)
secrets.concat(provider_config_meta_app_secrets(channel))
secrets.compact_blank.uniq
end
def provider_config_meta_app_secrets(channel)
return [] unless channel.respond_to?(:provider_config)
provider_config = channel.provider_config.to_h.with_indifferent_access
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
end
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
+8 -1
View File
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods
ALLOWED_LOGIN_METHODS: allowed_login_methods,
ACTIVE_PLATFORM_BANNERS: active_platform_banners
}
end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -0,0 +1,9 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -1,6 +1,8 @@
class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :events
def events
Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero?
@@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end
def meta_app_secrets
[
*instagram_channel_meta_app_secrets,
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil),
GlobalConfigService.load('FB_APP_SECRET', nil)
]
end
def instagram_channel_meta_app_secrets
instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) }
end
def instagram_channels_from_payload
Array(params.to_unsafe_hash[:entry]).flat_map do |entry|
instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id|
[
Channel::Instagram.find_by(instagram_id: instagram_id),
Channel::FacebookPage.find_by(instagram_id: instagram_id)
]
end
end.compact.uniq
end
def instagram_ids_from_entry(entry)
messages = entry[:messaging].presence || entry[:standby] || []
messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) }
end
def instagram_id_from_messaging(messaging)
return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present?
messaging.dig(:recipient, :id)
end
end
@@ -1,6 +1,8 @@
class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :process_payload
def process_payload
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
@@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
def meta_app_secrets
[
*channel_meta_app_secrets(whatsapp_channel),
GlobalConfigService.load('WHATSAPP_APP_SECRET', nil)
]
end
def whatsapp_channel
@whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
def meta_signature_verification_required?
return true if whatsapp_channel.blank?
return false unless whatsapp_channel.provider == 'whatsapp_cloud'
return true if channel_meta_app_secrets(whatsapp_channel).present?
whatsapp_channel.provider_config['source'] == 'embedded_signup'
end
def whatsapp_business_payload_channel
return unless params[:object] == 'whatsapp_business_account'
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number?
phone_number = params[:phone_number]
return false if phone_number.blank?
@@ -0,0 +1,20 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
+6 -2
View File
@@ -7,11 +7,15 @@ class UserDrop < BaseDrop
@obj.try(:available_name)
end
def email
@obj.try(:email)
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
end
+2
View File
@@ -48,3 +48,5 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse
end
end
MessageFinder.prepend_mod_with('MessageFinder')
+3
View File
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
@@ -27,6 +28,7 @@ export default {
LoadingState,
NetworkNotification,
UpdateBanner,
StatusBanner,
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
@@ -137,6 +139,7 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
async joinClientCall({ to, conversationId, callSid }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
leaveConference({ inboxId, conversationId, callSid }) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
params: { conversation_id: conversationId, call_sid: callSid },
})
.then(r => r.data);
}
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
(inboxesList.value || []).filter(isVoiceCallEnabled)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
@@ -35,10 +35,16 @@ const emit = defineEmits([
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const unreadCount = computed(() => props.chat.unread_count);
@@ -49,7 +49,11 @@ const rules = computed(() => ({
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
try {
return getRegexp(props.attribute.regexPattern).test(value);
} catch {
return false;
}
},
}),
},
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -30,6 +28,7 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
);
});
@@ -111,7 +108,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && !props.voiceEnabled) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -442,6 +444,7 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
slate:
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
amber:
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
};
return classMap[props.color];
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -113,6 +113,7 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
@@ -1,25 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -1,25 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -1,20 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -1,21 +1,21 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { formatDuration } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,15 +30,41 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
@@ -52,16 +78,32 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -70,11 +112,61 @@ const iconName = computed(() => {
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.providerCallId);
// Show "Join call" when the call is still ringing, no agent has claimed it,
// and the conversation is unassigned or assigned to the current user. Mirrors
// the eligibility used by FloatingCallWidget so the bubble can act as a
// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
// Suppress the button once this call is the local active session — the
// message status webhook may lag behind, so we can't rely on `status` alone
// to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
if (assignee?.id && assignee.id !== currentUserId.value) return false;
return true;
});
const recordingAttachment = computed(() => {
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
fileType: 'audio',
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
});
const handleJoinCall = async () => {
if (!canJoinCall.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeCall.value.inboxId,
callSid: activeCall.value.callSid,
});
}
await joinCall({
conversationId: conversationId.value,
inboxId: inboxId.value,
callSid: callSid.value,
});
};
</script>
<template>
<BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex overflow-hidden flex-col w-full max-w-sm">
<div class="flex gap-3 items-center p-3 w-full">
<div
class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -94,11 +186,24 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
<span v-if="subtext" class="text-xs text-n-slate-11">
{{ subtext }}
</span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div>
</BaseBubble>
</template>
@@ -1,21 +0,0 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,20 +0,0 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,33 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-if="message.buttons && message.buttons.length > 0"
class="flex flex-col gap-2"
>
<Button
v-for="(button, index) in message.buttons"
:key="index"
:label="button.text || button.title || 'Button'"
slate
class="!text-n-blue-11 w-full"
/>
</div>
</div>
</template>
@@ -10,22 +10,22 @@ defineProps({
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
<div class="flex flex-col gap-2 pt-2.5">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
class="text-sm prose prose-bubble"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<div class="flex justify-center items-center p-3">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<div class="flex justify-center items-center p-3">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
const DOC_EXTENSIONS = ['.doc'];
const mediaType = computed(() => {
if (props.message.mediaType) return props.message.mediaType;
const format = props.message.header?.format;
if (format) return format.toLowerCase();
const url = props.message.image_url || '';
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
return 'image';
});
const fileType = computed(() => {
const url = props.message.image_url || '';
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
});
</script>
<template>
<div
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<img
v-if="mediaType === 'image'"
:src="message.image_url"
class="object-cover w-full max-h-44 rounded-lg"
alt="Template media"
/>
<div
v-else-if="mediaType === 'video'"
class="overflow-hidden relative rounded-lg"
>
<video
:src="message.image_url"
class="object-cover w-full max-h-44"
controls
preload="metadata"
/>
</div>
<div v-else-if="mediaType === 'document'" class="flex items-center">
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
</div>
<span
v-if="message.content"
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const actions = computed(() => props.message.actions || []);
</script>
<template>
<div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-for="(action, index) in actions"
:key="index"
class="flex justify-center items-center p-3"
>
<Button
:label="action.title || action.text || 'Button'"
link
class="hover:!no-underline"
>
<template #icon>
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,105 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
const greet = findWhatsApp('greet');
const eventInvitation = findWhatsApp('event_invitation_static');
const orderConfirmation = findWhatsApp('order_confirmation');
const discountCoupon = findWhatsApp('discount_coupon');
const trainingVideo = findWhatsApp('training_video');
const twilioGreet = findTwilio('greet');
const shoeLaunch = findTwilio('shoe_launch');
const welcomeMessage = findTwilio('welcome_message_new');
const courseFeeReminder = findTwilio('course_fee_reminder');
</script>
<template>
<Story
title="Components/TemplatePreview"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WhatsApp - Simple Text">
<TemplatePreview
:template="greet"
:variables="getWhatsAppVariables(greet)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Simple Text (No Variables)">
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
</Variant>
<Variant title="WhatsApp - Call to Action Buttons">
<TemplatePreview
:template="eventInvitation"
:variables="getWhatsAppVariables(eventInvitation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Image Media">
<TemplatePreview
:template="orderConfirmation"
:variables="getWhatsAppVariables(orderConfirmation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Video Media">
<TemplatePreview
:template="trainingVideo"
:variables="getWhatsAppVariables(trainingVideo)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Copy Code">
<TemplatePreview
:template="discountCoupon"
:variables="getWhatsAppVariables(discountCoupon)"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio - Text">
<TemplatePreview
:template="twilioGreet"
:variables="twilioGreet.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Media">
<TemplatePreview
:template="shoeLaunch"
:variables="shoeLaunch.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Quick Reply">
<TemplatePreview
:template="welcomeMessage"
:variables="welcomeMessage.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Call to Action">
<TemplatePreview
:template="courseFeeReminder"
:variables="courseFeeReminder.variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { computed } from 'vue';
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
import {
PLATFORMS,
TEMPLATE_TYPES,
WA_HEADER_FORMATS,
WA_MEDIA_FORMATS,
} from 'dashboard/services/TemplateConstants';
import CardTemplate from './CardTemplate.vue';
import CallToActionTemplate from './CallToActionTemplate.vue';
import MediaTemplate from './MediaTemplate.vue';
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
import QuickReplyTemplate from './QuickReplyTemplate.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
variables: {
type: Object,
default: () => ({}),
},
platform: {
type: String,
required: true,
validator: value => Object.values(PLATFORMS).includes(value),
},
});
const COMPONENT_MAP = {
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
};
const substituteVariables = (text, variables) => {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
const value = variables[variable];
return value !== undefined && value !== '' ? value : `[${variable}]`;
});
};
const processedTemplate = computed(() => {
const normalized = TemplateNormalizer.normalize(
props.template,
props.platform
);
let content = '';
let imageUrl = '';
let title = '';
let footer = '';
if (props.platform === PLATFORMS.WHATSAPP) {
content = normalized.body?.text || '';
if (normalized.header) {
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
imageUrl = normalized.header.example?.header_handle?.[0] || '';
}
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
title = normalized.header.text || '';
}
}
footer = normalized.footer?.text || '';
} else {
content = normalized.body || '';
if (normalized.media && normalized.media.length > 0) {
imageUrl = normalized.media[0];
}
}
const buttons = normalized.buttons?.length
? normalized.buttons
: normalized.actions || [];
return {
...normalized,
content: substituteVariables(content, props.variables),
title: substituteVariables(title, props.variables),
footer: substituteVariables(footer, props.variables),
image_url: substituteVariables(imageUrl, props.variables),
buttons,
actions: normalized.actions || [],
};
});
const previewComponent = computed(
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
);
</script>
<template>
<div class="template-preview">
<component :is="previewComponent" :message="processedTemplate" />
</div>
</template>
@@ -0,0 +1,199 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
</script>
<template>
<Story
title="Components/TemplatePreviewExamples"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WA: Event Invitation (Buttons)">
<TemplatePreview
:template="whatsAppTemplates[0]"
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Purchase Receipt (Document)">
<TemplatePreview
:template="whatsAppTemplates[1]"
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Discount Coupon (Copy Code)">
<TemplatePreview
:template="whatsAppTemplates[2]"
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Support Callback (Phone)">
<TemplatePreview
:template="whatsAppTemplates[3]"
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Training Video (Video)">
<TemplatePreview
:template="whatsAppTemplates[4]"
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Order Confirmation (Image)">
<TemplatePreview
:template="whatsAppTemplates[5]"
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Product Launch (Image + Footer)">
<TemplatePreview
:template="whatsAppTemplates[6]"
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Technician Visit (Header + Buttons)">
<TemplatePreview
:template="whatsAppTemplates[7]"
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Greet (Simple Text)">
<TemplatePreview
:template="whatsAppTemplates[8]"
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Hello World (Header + Footer)">
<TemplatePreview
:template="whatsAppTemplates[9]"
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Feedback Request (Button)">
<TemplatePreview
:template="whatsAppTemplates[10]"
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Address Update (Header)">
<TemplatePreview
:template="whatsAppTemplates[11]"
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Delivery Confirmation">
<TemplatePreview
:template="whatsAppTemplates[12]"
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio: Shoe Launch (Media)">
<TemplatePreview
:template="twilioTemplates[0]"
:variables="twilioTemplates[0].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch Custom Price (Media)">
<TemplatePreview
:template="twilioTemplates[1]"
:variables="twilioTemplates[1].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch (Media)">
<TemplatePreview
:template="twilioTemplates[2]"
:variables="twilioTemplates[2].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Greet (Text)">
<TemplatePreview
:template="twilioTemplates[3]"
:variables="twilioTemplates[3].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Order Status (Text)">
<TemplatePreview
:template="twilioTemplates[4]"
:variables="twilioTemplates[4].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Hello World (Text)">
<TemplatePreview
:template="twilioTemplates[5]"
:variables="twilioTemplates[5].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message New (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[6]"
:variables="twilioTemplates[6].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[7]"
:variables="twilioTemplates[7].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[8]"
:variables="twilioTemplates[8].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
<TemplatePreview
:template="twilioTemplates[9]"
:variables="twilioTemplates[9].variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -8,7 +8,7 @@ defineProps({
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -0,0 +1,26 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<div v-if="message.title" class="text-base font-bold">
{{ message.title }}
</div>
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
<span v-dompurify-html="message.content" />
</div>
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
{{ message.footer }}
</div>
</div>
</template>
@@ -0,0 +1,6 @@
// Main Template Preview Component
export { default as TemplatePreview } from './TemplatePreview.vue';
// Core Services
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
@@ -0,0 +1,209 @@
export const twilioTemplates = [
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '100$',
},
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
friendly_name: 'shoe_launch',
template_type: 'media',
},
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '400$',
},
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
friendly_name: 'product_launch_custom_price',
template_type: 'media',
},
{
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
types: {
'twilio/media': {
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
friendly_name: 'product_launch',
template_type: 'media',
},
{
body: 'Hey {{1}}, how may I help you?',
types: {
'twilio/text': {
body: 'Hey {{1}}, how may I help you?',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
},
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
friendly_name: 'greet',
template_type: 'text',
},
{
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
types: {
'twilio/text': {
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
3: '12345',
},
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
friendly_name: 'order_status',
template_type: 'text',
},
{
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
types: {
'twilio/text': {
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
friendly_name: 'hello_world',
template_type: 'text',
},
{
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
actions: [
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Support_payload',
title: 'Support',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
friendly_name: 'welcome_message_new',
template_type: 'quick_reply',
},
{
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
types: {
'twilio/quick-reply': {
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
actions: [
{
id: 'Chatwoot Cloud_payload',
title: 'Chatwoot Cloud',
},
{
id: 'Self-hosted Chatwoot_payload',
title: 'Self-hosted Chatwoot',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
friendly_name: 'saas_whatsapp_question',
template_type: 'quick_reply',
},
{
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
actions: [
{
id: 'Support_payload',
title: 'Support',
},
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Demo_payload',
title: 'Demo',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
friendly_name: 'welcome_message',
template_type: 'quick_reply',
},
{
types: {
'twilio/call-to-action': {
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
friendly_name: 'course_fee_reminder',
template_type: 'call_to_action',
},
];
@@ -0,0 +1,408 @@
export const whatsAppTemplates = [
{
id: '1381151706284063',
name: 'event_invitation_static',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'F1',
param_name: 'event_name',
},
{
example: 'Dubai',
param_name: 'location',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '767076159336759',
name: 'purchase_receipt',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
example: {
header_handle: [
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
],
},
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
example: {
body_text: [['credit', 'CS Mutual', 'receipt']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1469258364071127',
name: 'discount_coupon',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
example: {
body_text_named_params: [
{
example: '30',
param_name: 'discount_percentage',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
example: ['SAVE1OFF'],
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1075221534579807',
name: 'support_callback',
status: 'APPROVED',
category: 'UTILITY',
language: 'en',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
{
example: '232323',
param_name: 'ticket_id',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+2112121212',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1023596726651144',
name: 'training_video',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'VIDEO',
example: {
header_handle: [
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
],
},
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'john',
param_name: 'name',
},
{
example: 'July 31',
param_name: 'date',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1106685194739985',
name: 'order_confirmation',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
example: {
body_text: [['blue canvas shoes']],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'POSITIONAL',
},
{
id: '1242180011253003',
name: 'product_launch',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
},
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'blue',
param_name: 'color',
},
],
},
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1449876326175680',
name: 'technician_visit',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
example: {
body_text: [
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
parameter_format: 'POSITIONAL',
},
{
id: '997298832221901',
name: 'greet',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'John',
param_name: 'customer_name',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '632315222954611',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameter_format: 'POSITIONAL',
},
{
id: '787864066907971',
name: 'feedback_request',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1938057163677205',
name: 'address_update',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
example: {
body_text: [['John', '123 Main St', 'support@telco.com']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1644094842949394',
name: 'delivery_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
type: 'BODY',
example: {
body_text: [['John', 'Jan 1, 2024']],
},
},
],
parameter_format: 'POSITIONAL',
},
];
// Helper function to get variable values from examples
export const getWhatsAppVariables = template => {
const variables = {};
template.components?.forEach(component => {
if (component.example?.body_text_named_params) {
component.example.body_text_named_params.forEach(param => {
variables[param.param_name] = param.example;
});
}
if (component.example?.body_text) {
component.example.body_text[0]?.forEach((value, index) => {
variables[(index + 1).toString()] = value;
});
}
});
return variables;
};
@@ -1,5 +1,7 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
defineProps({
title: {
@@ -18,7 +20,13 @@ defineProps({
type: Boolean,
default: false,
},
isBeta: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
</script>
<template>
@@ -37,9 +45,18 @@ defineProps({
</div>
<div class="flex flex-col items-start gap-1.5">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<div class="flex items-center gap-2">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<Label
v-if="isBeta && !isComingSoon"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
:label="t('GENERAL.BETA')"
color="blue"
compact
/>
</div>
<p class="text-n-slate-11 text-start text-sm">
{{ description }}
</p>
@@ -50,7 +67,7 @@ defineProps({
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span>
</div>
</button>
@@ -102,13 +102,14 @@ export default {
return this.v$.editedValue.$error;
},
errorMessage() {
if (this.v$.editedValue.url) {
if (this.v$.editedValue.url?.$invalid) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (!this.v$.editedValue.regexValidation) {
return this.regexCue
? this.regexCue
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
if (this.v$.editedValue.regexValidation?.$invalid) {
return (
this.regexCue ||
this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
@@ -134,9 +135,12 @@ export default {
editedValue: {
required,
regexValidation: value => {
return !(
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
);
if (!this.attributeRegex || !value) return true;
try {
return getRegexp(this.attributeRegex).test(value);
} catch {
return false;
}
},
},
};
@@ -0,0 +1,63 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const BANNER_COLOR_MAP = {
info: 'blue',
warning: 'amber',
error: 'ruby',
};
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const dismissedBannerIds = ref(
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
);
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
const visibleBanners = computed(() => {
const banners = globalConfig.value?.activePlatformBanners || [];
return banners.filter(
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
);
});
const formattedMessage = message =>
new MessageFormatter(message).formattedMessage;
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
const dismissBanner = banner => {
const key = dismissKey(banner);
if (dismissedBannerIds.value.includes(key)) return;
dismissedBannerIds.value.push(key);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
dismissedBannerIds.value
);
};
</script>
<template>
<Banner
v-for="banner in visibleBanners"
:key="banner.id"
:color="bannerColor(banner.banner_type)"
:action-label="t('GENERAL_SETTINGS.DISMISS')"
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
@action="dismissBanner(banner)"
>
<span
v-dompurify-html="formattedMessage(banner.banner_message)"
class="text-xs"
/>
</Banner>
</template>
@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -77,6 +77,10 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value;
});
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
@@ -90,6 +94,7 @@ const onItemClick = () => {
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:is-beta="isBeta"
:disabled="!isActive"
@click="onItemClick"
/>
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
@@ -51,6 +51,7 @@ import {
import {
appendSignature,
collapseSelection,
findNodeToInsertImage,
getContentNode,
insertAtCursor,
@@ -66,6 +67,7 @@ import {
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import { createTypingIndicator } from '@chatwoot/utils';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
}
function onKeydown(event) {
if (isEscape(event)) {
collapseSelection(editorView);
return true;
}
if (isEnterToSendEnabled()) {
handleLineBreakWhenEnterToSendEnabled(event);
}
if (isCmdPlusEnterToSendEnabled()) {
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
}
return false;
}
function createEditorView() {
@@ -744,6 +753,9 @@ function createEditorView() {
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
// PM keeps its selection on blur — clear the menu flags manually.
isTextSelected.value = false;
editorRoot.value?.classList.remove('has-selection');
emit('blur');
},
paste: (view, event) => {
@@ -13,10 +13,15 @@ import {
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { collapseSelection } from 'dashboard/helper/editorHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -75,6 +80,8 @@ export default {
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
embedPreviewPlugin(markdownEmbeds),
trailingParagraphPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
@@ -111,6 +118,12 @@ export default {
this.focusEditorInputField();
}
},
beforeUnmount() {
if (editorView) {
editorView.destroy();
editorView = null;
}
},
methods: {
createSlashPlugin() {
return suggestionsPlugin({
@@ -362,19 +375,33 @@ export default {
onKeyup() {
this.$emit('keyup');
},
onKeydown() {
onKeydown(view, event) {
this.$emit('keydown');
if (isEscape(event)) {
if (this.showSlashMenu) {
this.showSlashMenu = false;
this.slashSearchTerm = '';
this.slashMenuPosition = null;
return true;
}
collapseSelection(editorView);
return true;
}
return false;
},
onBlur() {
// ProseMirror keeps its selection on blur — clear the menu flag manually.
this.isTextSelected = false;
this.$refs.editor?.classList.remove('has-selection');
this.$emit('blur');
},
onFocus() {
this.$emit('focus');
},
checkSelection(editorState) {
const { from, to } = editorState.selection;
// Check if there's a selection (from and to are different)
const hasSelection = from !== to;
const { selection } = editorState;
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
const hasSelection = !selection.empty && !selection.node;
// If the selection state is the same as the previous state, do nothing
if (hasSelection === this.isTextSelected) return;
// Update the selection state
@@ -472,4 +499,9 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror .cw-embed-preview {
max-width: 36rem;
margin: 0.5rem 0 1rem;
}
</style>
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => {
return (
@@ -273,6 +273,10 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
const { image_send: imageSend } =
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
const tiktokAttachmentSupported = imageSend ?? true;
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -283,7 +287,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
this.isATiktokChannel
(this.isATiktokChannel && tiktokAttachmentSupported)
);
},
replyButtonLabel() {
@@ -706,6 +710,7 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -1025,6 +1030,8 @@ export default {
});
},
attachFile({ blob, file }) {
if (!this.showFileUpload && !this.isOnPrivateNote) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -22,6 +22,10 @@ const props = defineProps({
type: Array,
required: true,
},
autoPlay: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
@@ -309,6 +313,7 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
:autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -317,6 +322,7 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
:autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
@@ -47,7 +47,11 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -211,11 +215,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
// Test Voice (Twilio with voice_enabled)
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
expect(wrapper.vm.voiceCallEnabled).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -274,7 +278,8 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
expectedProperties.forEach(prop => {
@@ -1,11 +1,14 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -42,8 +45,8 @@ export function useCallSession() {
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
@@ -66,6 +69,7 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
callSid,
});
callsStore.setCallActive(callSid);
@@ -73,6 +77,11 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
@@ -1,7 +1,11 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
return {
inbox,
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
voiceCallEnabled,
voiceCallProvider,
};
};
@@ -7,6 +7,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'shared_files' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
@@ -1,5 +1,6 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
+1
View File
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -2,6 +2,7 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
Selection,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
@@ -122,6 +123,13 @@ export function cleanSignature(signature) {
}
}
// Strip `\<newline>` hardbreak markers trailing `--` after a signature slice
const stripDelimiterHardbreaks = body =>
body.replace(/(--)\s*(?:\\\s*)+$/, '$1');
// Strip standalone blank-paragraph markers (`\` on their own lines).
const stripTrailingBlankLine = body => body.replace(/\n(?:\s*\\\n)+$/, '');
/**
* Adds the signature delimiter to the beginning of the signature.
*
@@ -226,13 +234,19 @@ export function removeSignature(body, signature, channelType) {
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
newBody = stripDelimiterHardbreaks(
newBody.substring(0, signatureIndex)
).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
// strip any trailing blank-line markers
if (signatureIndex > -1) {
newBody = stripTrailingBlankLine(newBody);
}
}
return newBody;
@@ -273,6 +287,18 @@ export const scrollCursorIntoView = view => {
}
};
/**
* Collapse the current selection to a cursor near its head. Used to override
* the default Escape -> selectParentNode behavior which would otherwise keep
* the text highlight visible.
*
* @param {EditorView} view - The ProseMirror EditorView
*/
export const collapseSelection = view => {
const { tr, selection } = view.state;
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
+21 -10
View File
@@ -11,9 +11,29 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -0,0 +1,11 @@
import config from '../../../../config/markdown_embeds.yml';
// Gists rely on document.write() and can't render inline in the editor.
const NON_PREVIEWABLE_EMBEDS = new Set(['github_gist']);
export const embeds = Object.entries(config)
.filter(([key]) => !NON_PREVIEWABLE_EMBEDS.has(key))
.map(([, { regex, template }]) => ({
regex: new RegExp(regex),
template,
}));
@@ -16,6 +16,7 @@ import {
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
collapseSelection,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -335,6 +336,38 @@ describe('removeSignature', () => {
'This is a test\n\n'
);
});
it('strips blank-paragraph marker before the delimiter', () => {
expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe(
'hey'
);
});
it('strips multiple consecutive blank-paragraph markers before the delimiter', () => {
expect(
removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there')
).toBe('wewe');
});
it('strips dangling hardbreak when signature shared a paragraph with "--"', () => {
expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe(
'hey\n\n'
);
});
it('preserves trailing backslash in user text when appending', () => {
expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain(
'C:\\'
);
expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\');
expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\');
});
it('preserves trailing backslash in user text when removing', () => {
expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain(
'C:\\'
);
expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\');
expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain(
'C:\\'
);
expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\');
});
});
describe('removeSignature with stripped signature', () => {
@@ -454,6 +487,37 @@ describe('stripInlineBase64Images', () => {
});
});
describe('collapseSelection', () => {
it('collapses a text range to a cursor at its head', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hello world'),
});
// Build a TextSelection via the initial selection's constructor (avoids
// importing prosemirror-state, which isn't a direct dep).
const { doc, selection } = editorView.state;
editorView.dispatch(
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
);
expect(editorView.state.selection.empty).toBe(false);
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
expect(editorView.state.selection.head).toBe(6);
});
it('leaves an already-collapsed selection as a cursor', () => {
const editorView = new EditorView(document.body, {
state: createEditorState('Hi'),
});
collapseSelection(editorView);
expect(editorView.state.selection.empty).toBe(true);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
+72 -15
View File
@@ -22,13 +22,37 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
const call = message?.call || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
callSid: call.provider_call_id,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -36,10 +60,19 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
@@ -53,22 +86,38 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
conversationId,
callStatus: status,
callSid,
});
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (isNewCall) {
if (status === 'ringing') {
callsStore.addCall({
callSid,
conversationId,
@@ -77,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -83,7 +83,9 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -363,7 +365,19 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
@@ -17,6 +17,7 @@ import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import SharedFiles from './SharedFiles.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
@@ -297,6 +298,18 @@ onMounted(() => {
<ContactNotes :contact-id="contactId" />
</AccordionItem>
</div>
<div v-else-if="element.name === 'shared_files'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHARED_FILES')"
:is-open="isContactSidebarItemOpen('is_shared_files_open')"
compact
@toggle="
value => toggleSidebarUIState('is_shared_files_open', value)
"
>
<SharedFiles />
</AccordionItem>
</div>
</template>
</Draggable>
</div>
@@ -0,0 +1,421 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { formatBytes } from 'shared/helpers/FileHelper';
import {
dynamicTime,
formatDuration,
shortTimestamp,
} from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
ATTACHMENT_TYPES,
MEDIA_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Icon from 'next/icon/Icon.vue';
import FileIcon from 'next/icon/FileIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 6;
const FILES_PEEK_LIMIT = 3;
const { t } = useI18n();
const allAttachments = useMapGetter('getSelectedChatAttachments');
const attachmentsLoaded = useMapGetter('getSelectedChatAttachmentsLoaded');
const sortedAttachments = computed(() =>
[...allAttachments.value].sort(
(a, b) => (b.created_at || 0) - (a.created_at || 0)
)
);
const mediaAttachments = computed(() =>
sortedAttachments.value.filter(a => MEDIA_TYPES.includes(a.file_type))
);
const fileAttachments = computed(() =>
sortedAttachments.value.filter(
a => !MEDIA_TYPES.includes(a.file_type) && a.data_url
)
);
const showAllMedia = ref(false);
const showAllFiles = ref(false);
const visibleMedia = computed(() =>
showAllMedia.value
? mediaAttachments.value
: mediaAttachments.value.slice(0, MEDIA_PEEK_LIMIT)
);
const visibleFiles = computed(() =>
showAllFiles.value
? fileAttachments.value
: fileAttachments.value.slice(0, FILES_PEEK_LIMIT)
);
const mediaOverflow = computed(() => {
const total = mediaAttachments.value.length;
return total > MEDIA_PEEK_LIMIT ? total - (MEDIA_PEEK_LIMIT - 1) : 0;
});
const showGallery = ref(false);
const selectedAttachment = ref(null);
const downloadingId = ref(null);
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
const isVideoType = type =>
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
const isPlayableType = type => isVideoType(type) || isAudioType(type);
const durations = ref({});
const onLoadedMetadata = (attachment, event) => {
const seconds = event.target?.duration;
if (Number.isFinite(seconds) && seconds > 0) {
durations.value[attachment.id] = seconds;
}
};
const displayDuration = attachment => {
const seconds = durations.value[attachment.id];
return seconds ? formatDuration(Math.round(seconds)) : '';
};
const isOverflowTile = index =>
!showAllMedia.value &&
mediaOverflow.value > 0 &&
index === MEDIA_PEEK_LIMIT - 1;
const onTileActivate = (attachment, index) => {
if (isOverflowTile(index)) {
showAllMedia.value = true;
return;
}
selectedAttachment.value = attachment;
showGallery.value = true;
};
const failedThumbs = ref(new Set());
const failedPreviews = ref(new Set());
const imagePreviewSrc = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
return null;
};
const onPreviewError = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
if (
canRetryWithFull &&
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
) {
failedThumbs.value.add(id);
return;
}
failedPreviews.value.add(id);
};
const hasPreview = attachment =>
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
const hasVideoPreview = attachment =>
isVideoType(attachment.file_type) &&
attachment.data_url &&
!failedPreviews.value.has(attachment.id);
const fallbackIcon = type => {
if (type === ATTACHMENT_TYPES.AUDIO) return 'i-lucide-music';
if (isVideoType(type)) return 'i-lucide-video';
return 'i-lucide-image';
};
const displayName = attachment =>
fileNameFromUrl(attachment.data_url) ||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
const displaySize = attachment => {
if (attachment.file_size) return formatBytes(attachment.file_size);
if (attachment.extension) return attachment.extension.toUpperCase();
return '—';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
</script>
<template>
<div class="flex flex-col gap-5 p-2">
<div v-if="!attachmentsLoaded" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p
v-else-if="!mediaAttachments.length && !fileAttachments.length"
class="p-3 text-sm text-center text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ mediaAttachments.length }}
</span>
</h4>
<NextButton
v-if="mediaOverflow > 0"
ghost
slate
xs
trailing-icon
:icon="
showAllMedia ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
"
:label="
showAllMedia
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAllMedia = !showAllMedia"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
role="button"
tabindex="0"
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onTileActivate(attachment, index)"
@keydown.enter="onTileActivate(attachment, index)"
@keydown.space.prevent="onTileActivate(attachment, index)"
>
<template v-if="!isOverflowTile(index)">
<img
v-if="hasPreview(attachment)"
:src="imagePreviewSrc(attachment)"
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
loading="lazy"
:alt="fileNameFromUrl(attachment.data_url)"
@error="onPreviewError(attachment)"
/>
<video
v-else-if="hasVideoPreview(attachment)"
:src="`${attachment.data_url}#t=0.1`"
preload="metadata"
muted
playsinline
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
@error="onPreviewError(attachment)"
/>
<div
v-else
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
>
<Icon
:icon="fallbackIcon(attachment.file_type)"
class="size-6 text-n-slate-11"
/>
</div>
<audio
v-if="isAudioType(attachment.file_type) && attachment.data_url"
:src="attachment.data_url"
preload="metadata"
class="hidden"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
/>
<div
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
/>
<div
v-if="hasVideoPreview(attachment)"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
>
<div
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
>
<Icon
icon="i-lucide-play"
class="ms-0.5 size-3.5 text-n-black"
/>
</div>
</div>
<span
v-if="
isPlayableType(attachment.file_type) &&
displayDuration(attachment)
"
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
>
{{ displayDuration(attachment) }}
</span>
<span
v-if="displayTime(attachment)"
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
>
{{ displayTime(attachment) }}
</span>
<button
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
:disabled="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
</button>
</template>
<div
v-if="isOverflowTile(index)"
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
>
<span class="text-base font-semibold text-n-slate-12">
{{
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
count: mediaOverflow,
})
}}
</span>
</div>
</div>
</div>
</section>
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ fileAttachments.length }}
</span>
</h4>
<NextButton
v-if="fileAttachments.length > FILES_PEEK_LIMIT"
ghost
slate
xs
trailing-icon
:icon="
showAllFiles ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'
"
:label="
showAllFiles
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAllFiles = !showAllFiles"
/>
</header>
<ul class="flex flex-col gap-0.5">
<li
v-for="attachment in visibleFiles"
:key="attachment.id"
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg hover:bg-n-slate-3 group"
>
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
>
<FileIcon
:file-type="attachment.extension?.toLowerCase() || ''"
class="size-4 text-n-slate-11"
/>
</div>
<a
:href="attachment.data_url"
target="_blank"
rel="noopener noreferrer"
class="flex-1 min-w-0"
:title="displayName(attachment)"
>
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
{{ displayName(attachment) }}
</p>
<p class="text-xs text-n-slate-11">
{{ displaySize(attachment) }}
<template v-if="displayTime(attachment)">
· {{ displayTime(attachment) }}
</template>
</p>
</a>
<NextButton
ghost
slate
sm
icon="i-lucide-download"
class="opacity-0 group-hover:opacity-100"
:is-loading="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click="onDownloadFile(attachment)"
/>
</li>
</ul>
</section>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
:attachment="selectedAttachment"
:all-attachments="mediaAttachments"
auto-play
@close="showGallery = false"
/>
</div>
</template>
@@ -1,5 +1,6 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import store from '../../../store';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
'conversation_participating_manage',
];
const isFolderAvailable = async folderId => {
let folders = store.getters['customViews/getConversationCustomViews'];
if (!folders.length) {
await store.dispatch('customViews/get', 'conversation');
folders = store.getters['customViews/getConversationCustomViews'];
}
return folders.some(folder => folder.id === Number(folderId));
};
const redirectFolderListIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({ name: 'home', params: { accountId: to.params.accountId } });
};
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({
name: 'inbox_conversation',
params: {
accountId: to.params.accountId,
conversation_id: to.params.conversation_id,
},
});
};
export default {
routes: [
{
@@ -113,6 +145,7 @@ export default {
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
beforeEnter: redirectFolderListIfUnavailable,
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -125,6 +158,7 @@ export default {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
beforeEnter: redirectFolderConversationIfUnavailable,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
@@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
import { normalizeRegexPattern } from 'shared/helpers/Validators';
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -99,19 +100,10 @@ export default {
},
validations: {
displayName: {
required,
minLength: minLength(1),
},
description: {
required,
},
attributeModel: {
required,
},
attributeType: {
required,
},
displayName: { required, minLength: minLength(1) },
description: { required },
attributeModel: { required },
attributeType: { required },
attributeKey: {
required,
isKey(value) {
@@ -151,9 +143,7 @@ export default {
attribute_display_type: this.attributeType,
attribute_key: this.attributeKey,
attribute_values: this.attributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_pattern: normalizeRegexPattern(this.regexPattern),
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
@@ -2,7 +2,7 @@
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, minLength } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
@@ -41,16 +41,9 @@ export default {
};
},
validations: {
displayName: {
required,
},
attributeType: {
required,
},
description: {
required,
minLength: minLength(1),
},
displayName: { required },
attributeType: { required },
description: { required, minLength: minLength(1) },
attributeKey: {
required,
isKey(value) {
@@ -118,7 +111,7 @@ export default {
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
? getRegexp(this.selectedAttribute.regex_pattern).source
? getRegexp(this.selectedAttribute.regex_pattern).toString()
: null;
this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description;
@@ -144,9 +137,7 @@ export default {
attribute_description: this.description,
attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues,
regex_pattern: this.regexPattern
? new RegExp(this.regexPattern).toString()
: null,
regex_pattern: normalizeRegexPattern(this.regexPattern),
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -46,6 +47,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -169,19 +171,17 @@ export default {
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,7 +197,6 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -232,6 +231,24 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -812,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -30,7 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -11,7 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import CSATTemplate from 'dashboard/components-next/template-preview/CSATTemplate.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -0,0 +1,156 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -9,10 +9,12 @@ import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import { useAdmin } from 'dashboard/composables/useAdmin';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
@@ -109,6 +111,7 @@ const tableHeaders = computed(() => {
v-for="macro in items"
:key="macro.id"
:macro="macro"
:can-manage-public-macros="isAdmin"
@delete="openDeletePopup(macro)"
/>
</template>
@@ -8,6 +8,7 @@ import { MACRO_ACTION_TYPES } from './constants';
import { useAlert } from 'dashboard/composables';
import actionQueryGenerator from 'dashboard/helper/actionQueryGenerator.js';
import { useMacros } from 'dashboard/composables/useMacros';
import { useAdmin } from 'dashboard/composables/useAdmin';
const store = useStore();
const getters = useStoreGetters();
@@ -18,6 +19,7 @@ const router = useRouter();
const { t } = useI18n();
const { getMacroDropdownValues } = useMacros();
const { isAdmin } = useAdmin();
const macro = ref(null);
const mode = ref('CREATE');
@@ -33,6 +35,9 @@ provide('macroActionTypes', macroActionTypes);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const macroId = computed(() => route.params.macroId);
const isPublicMacroReadOnly = computed(
() => macro.value?.visibility === 'global' && !isAdmin.value
);
const fetchDropdownData = () => {
store.dispatch('agents/get');
@@ -92,7 +97,7 @@ const initNewMacro = () => {
action_params: [],
},
],
visibility: 'global',
visibility: isAdmin.value ? 'global' : 'personal',
};
};
@@ -110,6 +115,8 @@ watch(
);
const saveMacro = async macroData => {
if (isPublicMacroReadOnly.value) return;
try {
const action = mode.value === 'EDIT' ? 'macros/update' : 'macros/create';
const successMessage =
@@ -136,6 +143,8 @@ const saveMacro = async macroData => {
<MacroForm
v-if="macro && !uiFlags.isFetchingItem"
:macro-data="macro"
:can-manage-public-macros="isAdmin"
:read-only="isPublicMacroReadOnly"
@update:macro-data="macro = $event"
@submit="saveMacro"
/>
@@ -16,6 +16,14 @@ export default {
type: Object,
default: () => ({}),
},
canManagePublicMacros: {
type: Boolean,
default: true,
},
readOnly: {
type: Boolean,
default: false,
},
},
emits: ['submit'],
setup() {
@@ -112,19 +120,23 @@ export default {
<div
class="flex-1 w-full h-full max-h-full ltr:pl-12 ltr:pr-6 rtl:pl-6 rtl:pr-12 py-4 overflow-y-auto lg:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
>
<MacroNodes
v-model="macro.actions"
:files="files"
:errors="errors"
@add-new-node="appendNode"
@delete-node="deleteNode"
@reset-action="resetNode"
/>
<div :inert="readOnly" :class="{ 'opacity-75': readOnly }">
<MacroNodes
v-model="macro.actions"
:files="files"
:errors="errors"
@add-new-node="appendNode"
@delete-node="deleteNode"
@reset-action="resetNode"
/>
</div>
</div>
<div class="w-full lg:w-1/3 pb-4">
<MacroProperties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
:can-manage-public-macros="canManagePublicMacros"
:read-only="readOnly"
@update:name="updateName"
@update:visibility="updateVisibility"
@submit="submit"
@@ -17,8 +17,36 @@ export default {
type: String,
default: 'global',
},
canManagePublicMacros: {
type: Boolean,
default: true,
},
readOnly: {
type: Boolean,
default: false,
},
},
emits: ['update:name', 'update:visibility', 'submit'],
computed: {
isPublicVisibilityDisabled() {
return !this.canManagePublicMacros;
},
publicVisibilityDescription() {
if (this.readOnly) {
return this.$t(
'MACROS.EDITOR.VISIBILITY.GLOBAL.EDIT_DISABLED_DESCRIPTION'
);
}
if (this.isPublicVisibilityDisabled) {
return this.$t(
'MACROS.EDITOR.VISIBILITY.GLOBAL.CREATE_DISABLED_DESCRIPTION'
);
}
return this.$t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION');
},
},
methods: {
isActive(key) {
return this.macroVisibility === key
@@ -26,9 +54,14 @@ export default {
: 'bg-white dark:bg-n-solid-2 border-n-weak dark:border-n-strong';
},
onUpdateName(value) {
if (this.readOnly) return;
this.$emit('update:name', value);
},
onUpdateVisibility(value) {
if (this.readOnly) return;
if (value === 'global' && this.isPublicVisibilityDisabled) return;
this.$emit('update:visibility', value);
},
},
@@ -46,6 +79,7 @@ export default {
:placeholder="$t('MACROS.ADD.FORM.NAME.PLACEHOLDER')"
:error="v$.macro.name.$error ? $t('MACROS.ADD.FORM.NAME.ERROR') : null"
:class="{ error: v$.macro.name.$error }"
:readonly="readOnly"
@update:model-value="onUpdateName"
/>
</div>
@@ -55,8 +89,13 @@ export default {
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
type="button"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
:class="isActive('global')"
:disabled="isPublicVisibilityDisabled || readOnly"
:aria-describedby="
isPublicVisibilityDisabled ? 'macro-public-visibility-help' : null
"
@click="onUpdateVisibility('global')"
>
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
@@ -69,13 +108,18 @@ export default {
class="text-n-brand size-4"
/>
</div>
<p class="text-n-slate-11 text-label-small">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
<p
id="macro-public-visibility-help"
class="text-n-slate-11 text-label-small"
>
{{ publicVisibilityDescription }}
</p>
</button>
<button
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
type="button"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start"
:class="isActive('personal')"
:disabled="readOnly"
@click="onUpdateVisibility('personal')"
>
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
@@ -111,6 +155,7 @@ export default {
solid
:label="$t('MACROS.HEADER_BTN_TXT_SAVE')"
class="w-full"
:disabled="readOnly"
@click="$emit('submit')"
/>
</div>
@@ -11,6 +11,10 @@ const props = defineProps({
type: Object,
required: true,
},
canManagePublicMacros: {
type: Boolean,
default: true,
},
});
defineEmits(['delete']);
const { t } = useI18n();
@@ -32,6 +36,14 @@ const visibilityLabel = computed(() => {
: 'MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL';
return t(i18nKey);
});
const canManageMacro = computed(
() => props.canManagePublicMacros || props.macro.visibility !== 'global'
);
const editTooltip = computed(() =>
canManageMacro.value ? t('MACROS.EDIT.TOOLTIP') : t('MACROS.VIEW.TOOLTIP')
);
</script>
<template>
@@ -85,13 +97,14 @@ const visibilityLabel = computed(() => {
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
<Button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
v-tooltip.top="editTooltip"
icon="i-woot-edit-pen"
slate
sm
/>
</router-link>
<Button
v-if="canManageMacro"
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
icon="i-woot-bin"
slate
@@ -0,0 +1,79 @@
import { shallowMount } from '@vue/test-utils';
import MacroProperties from '../MacroProperties.vue';
const mountComponent = props =>
shallowMount(MacroProperties, {
props: {
macroName: 'Close conversation',
macroVisibility: 'personal',
...props,
},
global: {
provide: {
v$: {
macro: {
name: {
$error: false,
},
},
},
},
stubs: {
WootInput: true,
NextButton: true,
Icon: true,
},
},
});
describe('MacroProperties.vue', () => {
it('allows administrators to select public visibility', async () => {
const wrapper = mountComponent({ canManagePublicMacros: true });
const publicButton = wrapper.findAll('button')[0];
await publicButton.trigger('click');
expect(publicButton.attributes('disabled')).toBeUndefined();
expect(wrapper.emitted('update:visibility')?.[0]).toEqual(['global']);
});
it('disables public visibility for agents with helper copy', async () => {
const wrapper = mountComponent({ canManagePublicMacros: false });
const publicButton = wrapper.findAll('button')[0];
await publicButton.trigger('click');
expect(publicButton.attributes('disabled')).toBeDefined();
expect(wrapper.emitted('update:visibility')).toBeUndefined();
expect(wrapper.text()).toContain(
'Only administrators can create public macros.'
);
});
it('keeps existing public macros visibly selected when public is disabled', () => {
const wrapper = mountComponent({
canManagePublicMacros: false,
macroVisibility: 'global',
});
expect(wrapper.findComponent({ name: 'Icon' }).exists()).toBe(true);
});
it('shows existing public macros as read-only for agents', async () => {
const wrapper = mountComponent({
canManagePublicMacros: false,
macroVisibility: 'global',
readOnly: true,
});
const [publicButton, privateButton] = wrapper.findAll('button');
await privateButton.trigger('click');
expect(publicButton.attributes('disabled')).toBeDefined();
expect(privateButton.attributes('disabled')).toBeDefined();
expect(wrapper.emitted('update:visibility')).toBeUndefined();
expect(wrapper.text()).toContain(
'Only administrators can edit public macros.'
);
});
});

Some files were not shown because too many files have changed in this diff Show More