iachat/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue
Gabriel Jablonski b5757eea5d
fix(branding): add SuperAdmin-only notice on upgrade gates (#278)
* fix(branding): add SuperAdmin-only notice on upgrade gates

Some upgrade prompts (Kanban paywall, group creation form, group-disabled
banner in conversation view) are rendered only to SuperAdmins and link to
fazer.ai. Admins viewing those screens were worried that the fazer.ai
link was also being shown to their agents, even though it is not.

Add a discreet "Only system administrators can see this message" line
under each SuperAdmin-only block to make the audience explicit.

* fix(branding): inline SuperAdmin notice into Banner component

The notice was being rendered as a standalone <p> below the conversation
banner, which made it easy to miss. Add an optional noticeMessage prop
to the Banner component and render it inside the banner with italic +
reduced opacity styling, then pass it from the groups-disabled branch
of the MessagesView banner.
2026-04-28 13:20:38 -03:00

315 lines
9.6 KiB
Vue

<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import ContactsAPI from 'dashboard/api/contacts';
import wootConstants from 'dashboard/constants/globals';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
isCreating: { type: Boolean, default: false },
isGroupsDisabled: { type: Boolean, default: false },
isSuperAdmin: { type: Boolean, default: false },
});
const emit = defineEmits(['createGroup', 'discard']);
const { t } = useI18n();
const groupName = ref('');
const selectedInbox = ref(null);
const showInboxDropdown = ref(false);
const participants = ref([]);
const contactResults = ref([]);
const showContactsDropdown = ref(false);
const isSearching = ref(false);
const nameTouched = ref(false);
const participantsTouched = ref(false);
const participantsFocused = ref(false);
const inboxMenuItems = computed(() =>
props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'select',
}))
);
const contactMenuItems = computed(() =>
contactResults.value.map(contact => ({
id: contact.id,
label: contact.phone_number
? `${contact.name} (${contact.phone_number})`
: contact.name,
value: contact.id,
action: 'contact',
thumbnail: { name: contact.name, src: contact.thumbnail },
phoneNumber: contact.phone_number,
name: contact.name,
}))
);
const participantTags = computed(() =>
participants.value.map(p => p.name || p.phone_number)
);
const showNameError = computed(
() => nameTouched.value && !groupName.value.trim()
);
const showParticipantsError = computed(
() => participantsTouched.value && participants.value.length === 0
);
const isFormValid = computed(
() =>
selectedInbox.value &&
groupName.value.trim() &&
participants.value.length > 0
);
const searchContacts = debounce(
async query => {
if (!query || query.length < 2) {
contactResults.value = [];
showContactsDropdown.value = false;
return;
}
isSearching.value = true;
try {
const { data } = await ContactsAPI.search(query);
const selectedIds = participants.value.map(p => p.id);
contactResults.value = (data.payload || []).filter(
contact => contact.phone_number && !selectedIds.includes(contact.id)
);
showContactsDropdown.value = contactResults.value.length > 0;
} catch {
contactResults.value = [];
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleInboxAction = item => {
const inbox = props.inboxes.find(i => i.id === item.value);
selectedInbox.value = inbox;
showInboxDropdown.value = false;
};
const clearInbox = () => {
selectedInbox.value = null;
};
const handleAddParticipant = item => {
const contact = contactResults.value.find(c => c.id === item.value);
if (contact) {
participants.value = [...participants.value, contact];
participantsTouched.value = true;
contactResults.value = [];
showContactsDropdown.value = false;
}
};
const handleRemoveParticipant = index => {
participants.value = participants.value.filter((_, i) => i !== index);
participantsTouched.value = true;
};
const handleNameBlur = () => {
nameTouched.value = true;
};
const handleParticipantsFocus = () => {
participantsFocused.value = true;
};
const handleParticipantsBlur = () => {
showContactsDropdown.value = false;
if (participantsFocused.value && participants.value.length === 0) {
participantsTouched.value = true;
}
};
const resetForm = () => {
groupName.value = '';
selectedInbox.value = null;
participants.value = [];
contactResults.value = [];
showContactsDropdown.value = false;
showInboxDropdown.value = false;
nameTouched.value = false;
participantsTouched.value = false;
participantsFocused.value = false;
};
const handleSubmit = () => {
if (!isFormValid.value) return;
emit('createGroup', {
inboxId: selectedInbox.value.id,
subject: groupName.value.trim(),
participants: participants.value.map(p => p.phone_number),
});
};
defineExpose({ resetForm });
</script>
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 divide-y divide-n-strong overflow-visible">
<div
v-if="isGroupsDisabled"
class="flex items-start gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
>
<span class="i-lucide-triangle-alert text-base flex-shrink-0 mt-0.5" />
<div v-if="isSuperAdmin" class="flex flex-col gap-0.5">
<span>
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
<a
:href="wootConstants.FAZER_AI_GUIDES_URL"
target="_blank"
rel="noopener noreferrer"
class="underline font-medium"
>
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
</a>
</span>
<span class="text-xs opacity-70">
{{ t('GENERAL_SETTINGS.SUPER_ADMIN_ONLY_NOTICE') }}
</span>
</div>
<span v-else>
{{ t('GROUP.CREATE.GROUPS_DISABLED_NON_ADMIN') }}
</span>
</div>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
>
{{ t('GROUP.CREATE.INBOX_LABEL') }}
</label>
<div class="relative flex-1 min-w-0">
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ selectedInbox.name }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="clearInbox"
/>
</div>
<div v-else class="relative">
<Button
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="showInboxDropdown"
:menu-items="inboxMenuItems"
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
@action="handleInboxAction"
/>
</div>
</div>
</div>
<div
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.NAME_LABEL') }}
</label>
<div class="flex flex-col flex-1 min-w-0">
<input
v-model="groupName"
type="text"
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
@blur="handleNameBlur"
/>
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
</span>
</div>
</div>
<div class="relative flex flex-col gap-1 px-4 py-3">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap"
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
</label>
<TagInput
:model-value="participantTags"
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
mode="multiple"
:menu-items="contactMenuItems"
:show-dropdown="showContactsDropdown"
:is-loading="isSearching"
skip-label-dedup
:auto-open-dropdown="false"
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
@input="searchContacts"
@focus="handleParticipantsFocus"
@on-click-outside="handleParticipantsBlur"
@add="handleAddParticipant"
@remove="handleRemoveParticipant"
/>
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3">
<div />
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
@click="
resetForm();
emit('discard');
"
/>
<Button
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
color="blue"
size="sm"
:disabled="!isFormValid || isCreating || isGroupsDisabled"
:is-loading="isCreating"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>