feat(whatsapp): display click-to-chat ad referrals (#15424)

WhatsApp click-to-chat ad referrals are already stored on incoming
messages, but agents only see the customer’s text. This displays the
originating ad preview directly in the message bubble, including
available media, headline, body, and source link. Attribution
identifiers remain hidden.

### Things to know

- The card renders only when `content_attributes.referral` is present.
- Invalid or non-HTTP media and source URLs fall back safely.
- Existing messages without referral metadata are unchanged.

### How to test

1. Open a WhatsApp conversation containing an incoming message with
`content_attributes.referral`.
2. Confirm the ad preview appears above the customer text.
3. Confirm the source opens in a new tab and missing or invalid media
does not break the message bubble.


Fixes
https://linear.app/chatwoot/issue/CW-6206/add-whatsapp-ad-preview-support-in-chatwoot-inbox

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-08-12 13:28:33 +05:30
committed by GitHub
parent 541f91acae
commit 79405f76c4
4 changed files with 153 additions and 0 deletions

View File

@@ -42,6 +42,7 @@ import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import VoiceCallBubble from './bubbles/VoiceCall.vue';
import WhatsappFlowResponseBubble from './bubbles/WhatsappFlowResponse.vue';
import WhatsappReferral from './bubbles/Text/WhatsappReferral.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -370,6 +371,12 @@ const isMessageDeleted = computed(() => {
return props.contentAttributes?.deleted;
});
const shouldShowWhatsappReferral = computed(
() =>
variant.value === MESSAGE_VARIANTS.USER &&
!!props.contentAttributes?.referral
);
const payloadForContextMenu = computed(() => {
return {
id: props.id,
@@ -426,6 +433,7 @@ const shouldRenderMessage = computed(() => {
isUnsupported ||
isAnIntegrationMessage ||
hasWhatsappFlowResponse ||
shouldShowWhatsappReferral.value ||
isFailedMessage ||
hasExternalError
);
@@ -580,9 +588,14 @@ provideMessageContext({
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'flex-col items-start gap-2': shouldShowWhatsappReferral,
}"
@contextmenu="openContextMenu($event)"
>
<WhatsappReferral
v-if="shouldShowWhatsappReferral"
:referral="contentAttributes.referral"
/>
<Component :is="componentToRender" />
</div>
<MessageError

View File

@@ -0,0 +1,127 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
referral: { type: Object, required: true },
});
const { t } = useI18n();
const hasMediaError = ref(false);
const safeUrl = value => {
if (!value) return '';
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : '';
} catch {
return '';
}
};
const sourceUrl = computed(() => safeUrl(props.referral.sourceUrl));
const mediaUrl = computed(() =>
safeUrl(
props.referral.thumbnailUrl ||
props.referral.imageUrl ||
props.referral.mediaUrl ||
props.referral.videoUrl
)
);
const showMedia = computed(() => mediaUrl.value && !hasMediaError.value);
const isVideo = computed(
() =>
props.referral.mediaType === 'video' ||
props.referral.mediaContentType?.startsWith('video/')
);
const renderVideo = computed(
() => isVideo.value && !props.referral.thumbnailUrl
);
const handleMediaError = () => {
hasMediaError.value = true;
};
</script>
<template>
<div
class="w-full max-w-lg overflow-hidden border rounded-lg border-n-weak bg-n-alpha-1"
data-testid="whatsapp-referral"
>
<a
v-if="sourceUrl"
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="block text-current no-underline hover:no-underline"
>
<div
v-if="showMedia"
class="relative overflow-hidden bg-n-alpha-2 aspect-video"
>
<video
v-if="renderVideo"
:src="mediaUrl"
muted
preload="metadata"
class="object-cover w-full h-full skip-context-menu"
@error="handleMediaError"
/>
<img
v-else
:src="mediaUrl"
:alt="referral.headline || t('CONVERSATION.WHATSAPP_REFERRAL.AD')"
class="object-cover w-full h-full skip-context-menu"
@error="handleMediaError"
/>
<div
v-if="isVideo"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-n-alpha-black2"
>
<div
class="flex items-center justify-center rounded-full shadow-md size-10 bg-n-solid-1"
>
<Icon icon="i-lucide-play" class="size-4 ms-0.5 text-n-slate-12" />
</div>
</div>
</div>
<div class="p-3">
<div class="flex items-center gap-1 text-xs text-n-slate-11">
<span>{{ t('CONVERSATION.WHATSAPP_REFERRAL.AD') }}</span>
<Icon icon="i-lucide-external-link" class="size-3" />
</div>
<p
v-if="referral.headline"
class="mt-1 mb-0 font-medium break-words text-n-slate-12"
>
{{ referral.headline }}
</p>
<p
v-if="referral.body"
class="mt-1 mb-0 whitespace-pre-line break-words line-clamp-3 text-n-slate-11"
>
{{ referral.body }}
</p>
</div>
</a>
<div v-else class="p-3">
<div class="text-xs text-n-slate-11">
{{ t('CONVERSATION.WHATSAPP_REFERRAL.AD') }}
</div>
<p
v-if="referral.headline"
class="mt-1 mb-0 font-medium break-words text-n-slate-12"
>
{{ referral.headline }}
</p>
<p
v-if="referral.body"
class="mt-1 mb-0 whitespace-pre-line break-words line-clamp-3 text-n-slate-11"
>
{{ referral.body }}
</p>
</div>
</div>
</template>

View File

@@ -68,6 +68,16 @@ const MessageControl = Symbol('MessageControl');
* @property {string|null} [bccEmail] - BCC email addresses
* @property {Object} [whatsappFlowResponse] - WhatsApp Flow response metadata
* @property {Record<string, unknown>|string} [whatsappFlowResponse.responseJson] - Structured fields or the raw response submitted by the contact
* @property {Object} [referral] - WhatsApp click-to-chat ad metadata
* @property {string} [referral.sourceUrl] - URL of the originating ad
* @property {string} [referral.headline] - Headline of the originating ad
* @property {string} [referral.body] - Body copy of the originating ad
* @property {string} [referral.mediaType] - Media type of the originating ad
* @property {string} [referral.thumbnailUrl] - Thumbnail of the originating ad
* @property {string} [referral.imageUrl] - Image URL of a Cloud API referral
* @property {string} [referral.videoUrl] - Video URL of a Cloud API referral
* @property {string} [referral.mediaUrl] - Media URL of a Twilio referral
* @property {string} [referral.mediaContentType] - Media content type of a Twilio referral
*/
/**

View File

@@ -67,6 +67,9 @@
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"UNSUPPORTED_MESSAGE_WHATSAPP": "This message is unsupported. You can view this message on the WhatsApp app.",
"WHATSAPP_FLOW_RESPONSE": "Submitted a flow response",
"WHATSAPP_REFERRAL": {
"AD": "Ad"
},
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",