Feat: Agregar soporte para envío de Contacts, Polls y Events
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s

- Backend: Nuevo soporte en endpoint /send para tipos contact, poll, event
- UI: Modales para crear y enviar contactos, encuestas y eventos
- Visualización: Componentes MessagePoll y MessageEvent para mostrar mensajes recibidos
- Tipos: Agregar PollInfo, EventInfo y tipo 'event' a MessageType
This commit is contained in:
2025-12-04 12:06:35 -06:00
parent 48f23c512b
commit cb846d0c56
10 changed files with 1443 additions and 46 deletions

View File

@@ -0,0 +1,150 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Enviar Contacto</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<!-- Contacts list -->
<div
v-for="(contact, index) in contacts"
:key="index"
class="p-3 rounded-lg bg-[var(--wa-bg-light)] space-y-3"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-[var(--wa-text-muted)]">
Contacto {{ index + 1 }}
</span>
<UButton
v-if="contacts.length > 1"
variant="ghost"
icon="i-lucide-trash-2"
size="xs"
color="error"
@click="removeContact(index)"
/>
</div>
<UInput
v-model="contact.displayName"
placeholder="Nombre del contacto"
icon="i-lucide-user"
/>
<UInput
v-model="contact.phoneNumber"
placeholder="Número de teléfono (ej: +54911...)"
icon="i-lucide-phone"
/>
<UInput
v-model="contact.organization"
placeholder="Organización (opcional)"
icon="i-lucide-building"
/>
</div>
<!-- Add contact button -->
<UButton
v-if="contacts.length < 5"
variant="outline"
icon="i-lucide-plus"
block
@click="addContact"
>
Agregar otro contacto
</UButton>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Enviar
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface ContactInfo {
displayName: string
phoneNumber: string
organization?: string
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [contacts: ContactInfo[]]
}>()
const isSending = ref(false)
const contacts = ref<ContactInfo[]>([
{ displayName: '', phoneNumber: '', organization: '' }
])
const isValid = computed(() => {
return contacts.value.every(c =>
c.displayName.trim() && c.phoneNumber.trim()
)
})
const addContact = () => {
if (contacts.value.length < 5) {
contacts.value.push({ displayName: '', phoneNumber: '', organization: '' })
}
}
const removeContact = (index: number) => {
contacts.value.splice(index, 1)
}
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const validContacts = contacts.value.map(c => ({
displayName: c.displayName.trim(),
phoneNumber: c.phoneNumber.trim(),
organization: c.organization?.trim() || undefined
}))
emit('send', validContacts)
// Reset form
contacts.value = [{ displayName: '', phoneNumber: '', organization: '' }]
isOpen.value = false
} finally {
isSending.value = false
}
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
contacts.value = [{ displayName: '', phoneNumber: '', organization: '' }]
}
})
</script>

View File

@@ -0,0 +1,257 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Crear Evento</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4 max-h-[60vh] overflow-y-auto">
<!-- Event name -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Nombre del evento *
</label>
<UInput
v-model="eventName"
placeholder="Reunión de equipo"
icon="i-lucide-calendar"
/>
</div>
<!-- Start date/time -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Fecha inicio *
</label>
<UInput
v-model="startDate"
type="date"
/>
</div>
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Hora inicio *
</label>
<UInput
v-model="startTime"
type="time"
/>
</div>
</div>
<!-- End date/time (optional) -->
<div class="flex items-center gap-2 mb-2">
<UCheckbox v-model="hasEndDate" />
<span class="text-sm text-[var(--wa-text-muted)]">Agregar fecha de fin</span>
</div>
<div v-if="hasEndDate" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Fecha fin
</label>
<UInput
v-model="endDate"
type="date"
/>
</div>
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Hora fin
</label>
<UInput
v-model="endTime"
type="time"
/>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Descripción (opcional)
</label>
<UTextarea
v-model="description"
placeholder="Detalles del evento..."
:rows="2"
/>
</div>
<!-- Location -->
<div class="flex items-center gap-2 mb-2">
<UCheckbox v-model="hasLocation" />
<span class="text-sm text-[var(--wa-text-muted)]">Agregar ubicación</span>
</div>
<div v-if="hasLocation" class="space-y-3 p-3 rounded-lg bg-[var(--wa-bg-light)]">
<UInput
v-model="locationName"
placeholder="Nombre del lugar"
icon="i-lucide-map-pin"
/>
<UInput
v-model="locationAddress"
placeholder="Dirección"
icon="i-lucide-navigation"
/>
<div class="grid grid-cols-2 gap-3">
<UInput
v-model.number="latitude"
type="number"
step="any"
placeholder="Latitud"
/>
<UInput
v-model.number="longitude"
type="number"
step="any"
placeholder="Longitud"
/>
</div>
<p class="text-xs text-[var(--wa-text-muted)]">
Las coordenadas son opcionales pero permiten mostrar el mapa
</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Crear evento
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface EventData {
name: string
startDate: string
endDate?: string
description?: string
location?: {
name?: string
address?: string
latitude?: number
longitude?: number
}
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [event: EventData]
}>()
const isSending = ref(false)
// Form fields
const eventName = ref('')
const startDate = ref('')
const startTime = ref('')
const hasEndDate = ref(false)
const endDate = ref('')
const endTime = ref('')
const description = ref('')
const hasLocation = ref(false)
const locationName = ref('')
const locationAddress = ref('')
const latitude = ref<number | undefined>(undefined)
const longitude = ref<number | undefined>(undefined)
const isValid = computed(() => {
return eventName.value.trim() && startDate.value && startTime.value
})
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const startDateTime = new Date(`${startDate.value}T${startTime.value}`)
const eventData: EventData = {
name: eventName.value.trim(),
startDate: startDateTime.toISOString()
}
if (hasEndDate.value && endDate.value && endTime.value) {
const endDateTime = new Date(`${endDate.value}T${endTime.value}`)
eventData.endDate = endDateTime.toISOString()
}
if (description.value.trim()) {
eventData.description = description.value.trim()
}
if (hasLocation.value) {
eventData.location = {}
if (locationName.value.trim()) {
eventData.location.name = locationName.value.trim()
}
if (locationAddress.value.trim()) {
eventData.location.address = locationAddress.value.trim()
}
if (latitude.value !== undefined && longitude.value !== undefined) {
eventData.location.latitude = latitude.value
eventData.location.longitude = longitude.value
}
}
emit('send', eventData)
// Reset form
resetForm()
isOpen.value = false
} finally {
isSending.value = false
}
}
const resetForm = () => {
eventName.value = ''
startDate.value = ''
startTime.value = ''
hasEndDate.value = false
endDate.value = ''
endTime.value = ''
description.value = ''
hasLocation.value = false
locationName.value = ''
locationAddress.value = ''
latitude.value = undefined
longitude.value = undefined
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
resetForm()
// Set default start date to today
const today = new Date()
startDate.value = today.toISOString().split('T')[0]
startTime.value = '12:00'
}
})
</script>

View File

@@ -135,6 +135,20 @@
:from-me="message.fromMe"
/>
<!-- Poll -->
<MessagePoll
v-else-if="message.type === 'poll' && message.poll"
:poll="message.poll"
:from-me="message.fromMe"
/>
<!-- Event -->
<MessageEvent
v-else-if="message.type === 'event' && message.event"
:event="message.event"
:from-me="message.fromMe"
/>
<!-- Unknown/unsupported type -->
<div
v-else-if="message.type === 'unknown' || !message.content"
@@ -220,6 +234,8 @@ import MessageDocument from './content/MessageDocument.vue'
import MessageSticker from './content/MessageSticker.vue'
import MessageContact from './content/MessageContact.vue'
import MessageLocation from './content/MessageLocation.vue'
import MessagePoll from './content/MessagePoll.vue'
import MessageEvent from './content/MessageEvent.vue'
import ReactionPicker from './ReactionPicker.vue'
interface Props {

View File

@@ -174,6 +174,24 @@
<p class="text-white font-medium">Suelta los archivos aquí</p>
</div>
</div>
<!-- Contact Send Modal -->
<MessagesContactSendModal
v-model:open="showContactModal"
@send="handleSendContact"
/>
<!-- Poll Send Modal -->
<MessagesPollSendModal
v-model:open="showPollModal"
@send="handleSendPoll"
/>
<!-- Event Send Modal -->
<MessagesEventSendModal
v-model:open="showEventModal"
@send="handleSendEvent"
/>
</div>
</template>
@@ -190,9 +208,37 @@ const props = withDefaults(defineProps<Props>(), {
replyingTo: null
})
interface ContactInfo {
displayName: string
phoneNumber: string
organization?: string
}
interface PollData {
name: string
options: string[]
selectableCount: number
}
interface EventData {
name: string
startDate: string
endDate?: string
description?: string
location?: {
name?: string
address?: string
latitude?: number
longitude?: number
}
}
const emit = defineEmits<{
send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
sendVoice: [audioFile: File]
sendContact: [contacts: ContactInfo[], quotedId?: string]
sendPoll: [poll: PollData, quotedId?: string]
sendEvent: [event: EventData, quotedId?: string]
cancelReply: []
typing: []
recording: [isRecording: boolean]
@@ -212,6 +258,11 @@ const isDragging = ref(false)
const stickerModes = ref<boolean[]>([])
const showDebug = ref(false)
// Modal states
const showContactModal = ref(false)
const showPollModal = ref(false)
const showEventModal = ref(false)
// File size limits (in bytes) - should match server
const MAX_SIZES: Record<string, number> = {
image: 16 * 1024 * 1024, // 16 MB
@@ -317,6 +368,21 @@ const attachmentMenuItems = [
label: 'Documento',
icon: 'i-lucide-file',
onSelect: () => documentInput.value?.click()
}],
[{
label: 'Contacto',
icon: 'i-lucide-user',
onSelect: () => showContactModal.value = true
}],
[{
label: 'Encuesta',
icon: 'i-lucide-bar-chart-2',
onSelect: () => showPollModal.value = true
}],
[{
label: 'Evento',
icon: 'i-lucide-calendar',
onSelect: () => showEventModal.value = true
}]
]
@@ -441,5 +507,20 @@ const formatDuration = (seconds: number): string => {
const getTypePlaceholder = (type: MessageType): string => {
return getMessageTypePlaceholder(type)
}
// Handle contact send from modal
const handleSendContact = (contacts: ContactInfo[]) => {
emit('sendContact', contacts, props.replyingTo?.messageId)
}
// Handle poll send from modal
const handleSendPoll = (poll: PollData) => {
emit('sendPoll', poll, props.replyingTo?.messageId)
}
// Handle event send from modal
const handleSendEvent = (eventData: EventData) => {
emit('sendEvent', eventData, props.replyingTo?.messageId)
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<UModal v-model:open="isOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Crear Encuesta</h3>
<UButton
variant="ghost"
icon="i-lucide-x"
size="sm"
@click="isOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<!-- Poll question -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-1">
Pregunta
</label>
<UInput
v-model="pollName"
placeholder="¿Cuál es tu opción favorita?"
icon="i-lucide-help-circle"
/>
</div>
<!-- Options -->
<div>
<label class="block text-sm font-medium text-[var(--wa-text-muted)] mb-2">
Opciones (mínimo 2, máximo 12)
</label>
<div class="space-y-2">
<div
v-for="(option, index) in options"
:key="index"
class="flex items-center gap-2"
>
<span class="text-sm text-[var(--wa-text-muted)] w-6">
{{ index + 1 }}.
</span>
<UInput
v-model="options[index]"
:placeholder="`Opción ${index + 1}`"
class="flex-1"
/>
<UButton
v-if="options.length > 2"
variant="ghost"
icon="i-lucide-x"
size="xs"
color="error"
@click="removeOption(index)"
/>
</div>
</div>
<UButton
v-if="options.length < 12"
variant="outline"
icon="i-lucide-plus"
size="sm"
class="mt-2"
@click="addOption"
>
Agregar opción
</UButton>
</div>
<!-- Allow multiple selection -->
<div class="flex items-center justify-between p-3 rounded-lg bg-[var(--wa-bg-light)]">
<div>
<p class="text-sm font-medium text-white">Selección múltiple</p>
<p class="text-xs text-[var(--wa-text-muted)]">
Permitir seleccionar más de una opción
</p>
</div>
<USwitch v-model="allowMultiple" />
</div>
<!-- Max selections (only if multiple) -->
<div v-if="allowMultiple" class="flex items-center gap-3">
<label class="text-sm text-[var(--wa-text-muted)]">
Máximo de selecciones:
</label>
<UInput
v-model.number="maxSelections"
type="number"
:min="1"
:max="validOptions.length"
class="w-20"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton variant="ghost" @click="isOpen = false">
Cancelar
</UButton>
<UButton
:disabled="!isValid"
:loading="isSending"
@click="handleSend"
>
Crear encuesta
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
interface PollData {
name: string
options: string[]
selectableCount: number
}
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
send: [poll: PollData]
}>()
const isSending = ref(false)
const pollName = ref('')
const options = ref<string[]>(['', ''])
const allowMultiple = ref(false)
const maxSelections = ref(1)
const validOptions = computed(() =>
options.value.filter(o => o.trim())
)
const isValid = computed(() => {
return pollName.value.trim() && validOptions.value.length >= 2
})
const addOption = () => {
if (options.value.length < 12) {
options.value.push('')
}
}
const removeOption = (index: number) => {
if (options.value.length > 2) {
options.value.splice(index, 1)
}
}
const handleSend = async () => {
if (!isValid.value) return
isSending.value = true
try {
const poll: PollData = {
name: pollName.value.trim(),
options: validOptions.value,
selectableCount: allowMultiple.value
? Math.min(maxSelections.value, validOptions.value.length)
: 1
}
emit('send', poll)
// Reset form
resetForm()
isOpen.value = false
} finally {
isSending.value = false
}
}
const resetForm = () => {
pollName.value = ''
options.value = ['', '']
allowMultiple.value = false
maxSelections.value = 1
}
// Reset form when modal opens
watch(isOpen, (open) => {
if (open) {
resetForm()
}
})
// Adjust max selections when options change
watch(validOptions, (opts) => {
if (maxSelections.value > opts.length) {
maxSelections.value = opts.length || 1
}
})
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="min-w-[200px] max-w-[280px]">
<!-- Event header -->
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-lucide-calendar" class="w-5 h-5" :class="iconClass" />
<span class="font-medium" :class="textClass">Evento</span>
</div>
<!-- Event name -->
<p class="font-medium text-lg mb-2" :class="textClass">
{{ event.name }}
</p>
<!-- Date and time -->
<div class="space-y-1 mb-2">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-clock" class="w-4 h-4" :class="mutedTextClass" />
<span class="text-sm" :class="textClass">
{{ formatDate(event.startDate) }}
</span>
</div>
<div v-if="event.endDate" class="flex items-center gap-2">
<UIcon name="i-lucide-arrow-right" class="w-4 h-4" :class="mutedTextClass" />
<span class="text-sm" :class="textClass">
{{ formatDate(event.endDate) }}
</span>
</div>
</div>
<!-- Description -->
<p v-if="event.description" class="text-sm mb-2" :class="mutedTextClass">
{{ event.description }}
</p>
<!-- Location -->
<div
v-if="event.location"
class="flex items-start gap-2 p-2 rounded-lg"
:class="locationBgClass"
>
<UIcon name="i-lucide-map-pin" class="w-4 h-4 mt-0.5" :class="iconClass" />
<div class="flex-1 min-w-0">
<p v-if="event.location.name" class="text-sm font-medium" :class="textClass">
{{ event.location.name }}
</p>
<p v-if="event.location.address" class="text-xs" :class="mutedTextClass">
{{ event.location.address }}
</p>
<a
v-if="event.location.latitude && event.location.longitude"
:href="mapsUrl"
target="_blank"
class="text-xs underline"
:class="linkClass"
>
Ver en mapa
</a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface EventLocation {
name?: string
address?: string
latitude?: number
longitude?: number
}
interface EventInfo {
name: string
startDate: string
endDate?: string
description?: string
location?: EventLocation
}
interface Props {
event: EventInfo
fromMe?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fromMe: false
})
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleString('es-AR', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const mapsUrl = computed(() => {
if (!props.event.location?.latitude || !props.event.location?.longitude) return ''
const lat = props.event.location.latitude
const lng = props.event.location.longitude
return `https://www.google.com/maps?q=${lat},${lng}`
})
const textClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const iconClass = computed(() =>
props.fromMe ? 'text-white/80' : 'text-[var(--wa-green)]'
)
const mutedTextClass = computed(() =>
props.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
)
const locationBgClass = computed(() =>
props.fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'
)
const linkClass = computed(() =>
props.fromMe ? 'text-white/80 hover:text-white' : 'text-[var(--wa-blue)] hover:text-[var(--wa-green)]'
)
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="min-w-[200px] max-w-[280px]">
<!-- Poll header -->
<div class="flex items-center gap-2 mb-2">
<UIcon name="i-lucide-bar-chart-2" class="w-5 h-5" :class="iconClass" />
<span class="font-medium" :class="textClass">Encuesta</span>
</div>
<!-- Poll question -->
<p class="font-medium mb-3" :class="textClass">
{{ poll.name }}
</p>
<!-- Poll options -->
<div class="space-y-2">
<div
v-for="(option, index) in poll.options"
:key="index"
class="relative rounded-lg overflow-hidden"
:class="optionBgClass"
>
<!-- Background bar for vote count -->
<div
v-if="poll.votes && poll.votes[index]"
class="absolute inset-y-0 left-0 transition-all duration-300"
:class="voteBgClass"
:style="{ width: getVotePercentage(index) + '%' }"
/>
<!-- Option content -->
<div class="relative flex items-center justify-between px-3 py-2">
<span class="text-sm" :class="optionTextClass">{{ option }}</span>
<span
v-if="poll.votes && poll.votes[index]"
class="text-xs font-medium"
:class="voteCountClass"
>
{{ poll.votes[index] }}
</span>
</div>
</div>
</div>
<!-- Poll footer -->
<div class="flex items-center justify-between mt-3 text-xs" :class="mutedTextClass">
<span>{{ totalVotes }} voto{{ totalVotes !== 1 ? 's' : '' }}</span>
<span v-if="poll.selectableCount > 1">
Seleccion multiple ({{ poll.selectableCount }})
</span>
</div>
</div>
</template>
<script setup lang="ts">
interface PollInfo {
name: string
options: string[]
votes?: number[]
selectableCount?: number
}
interface Props {
poll: PollInfo
fromMe?: boolean
}
const props = withDefaults(defineProps<Props>(), {
fromMe: false
})
const totalVotes = computed(() => {
if (!props.poll.votes) return 0
return props.poll.votes.reduce((sum, v) => sum + v, 0)
})
const getVotePercentage = (index: number): number => {
if (!props.poll.votes || totalVotes.value === 0) return 0
return Math.round((props.poll.votes[index] / totalVotes.value) * 100)
}
const textClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const iconClass = computed(() =>
props.fromMe ? 'text-white/80' : 'text-[var(--wa-green)]'
)
const mutedTextClass = computed(() =>
props.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
)
const optionBgClass = computed(() =>
props.fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'
)
const optionTextClass = computed(() =>
props.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
)
const voteBgClass = computed(() =>
props.fromMe ? 'bg-white/20' : 'bg-[var(--wa-green)]/20'
)
const voteCountClass = computed(() =>
props.fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'
)
</script>

View File

@@ -231,6 +231,9 @@
:replying-to="replyingTo"
@send="handleSendMessage"
@send-voice="handleSendVoice"
@send-contact="handleSendContact"
@send-poll="handleSendPoll"
@send-event="handleSendEvent"
@cancel-reply="replyingTo = null"
@typing="handleTyping"
@recording="handleRecordingPresence"
@@ -647,6 +650,121 @@ const handleRecordingPresence = (isRecording: boolean) => {
}
}
// Handle contact send
interface ContactInfo {
displayName: string
phoneNumber: string
organization?: string
}
const handleSendContact = async (contacts: ContactInfo[], quotedId?: string) => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
const instanceId = selectedInstance.value.value
const chatId = selectedChat.value.id
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
method: 'POST',
body: {
type: 'contact',
contacts,
quotedMessageId: quotedId || replyingTo.value?.messageId
}
})
replyingTo.value = null
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
} catch (e: any) {
console.error('Error sending contact:', e)
toast.add({
title: 'Error de envío',
description: e?.data?.message || e?.message || 'Error al enviar el contacto',
color: 'error',
duration: 5000
})
}
}
// Handle poll send
interface PollData {
name: string
options: string[]
selectableCount: number
}
const handleSendPoll = async (poll: PollData, quotedId?: string) => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
const instanceId = selectedInstance.value.value
const chatId = selectedChat.value.id
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
method: 'POST',
body: {
type: 'poll',
...poll,
quotedMessageId: quotedId || replyingTo.value?.messageId
}
})
replyingTo.value = null
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
} catch (e: any) {
console.error('Error sending poll:', e)
toast.add({
title: 'Error de envío',
description: e?.data?.message || e?.message || 'Error al enviar la encuesta',
color: 'error',
duration: 5000
})
}
}
// Handle event send
interface EventData {
name: string
startDate: string
endDate?: string
description?: string
location?: {
name?: string
address?: string
latitude?: number
longitude?: number
}
}
const handleSendEvent = async (eventData: EventData, quotedId?: string) => {
if (!selectedInstance.value?.value || !selectedChat.value) return
try {
const instanceId = selectedInstance.value.value
const chatId = selectedChat.value.id
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
method: 'POST',
body: {
type: 'event',
...eventData,
quotedMessageId: quotedId || replyingTo.value?.messageId
}
})
replyingTo.value = null
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
} catch (e: any) {
console.error('Error sending event:', e)
toast.add({
title: 'Error de envío',
description: e?.data?.message || e?.message || 'Error al enviar el evento',
color: 'error',
duration: 5000
})
}
}
// Reload chats for current instance
const reloadChats = async () => {
if (!selectedInstance.value?.value) return

View File

@@ -15,6 +15,7 @@ export type MessageType =
| 'location'
| 'reaction'
| 'poll'
| 'event'
| 'unknown'
// Estados de mensaje
@@ -79,6 +80,50 @@ export interface ContactInfo {
phones?: string[]
}
/**
* Información de encuesta
*/
export interface PollInfo {
/** Nombre/pregunta de la encuesta */
name: string
/** Opciones de la encuesta */
options: string[]
/** Votos por opción */
votes?: number[]
/** Cantidad máxima de selecciones permitidas */
selectableCount?: number
}
/**
* Información de ubicación de evento
*/
export interface EventLocationInfo {
/** Nombre del lugar */
name?: string
/** Dirección */
address?: string
/** Latitud */
latitude?: number
/** Longitud */
longitude?: number
}
/**
* Información de evento
*/
export interface EventInfo {
/** Nombre del evento */
name: string
/** Fecha y hora de inicio */
startDate: string
/** Fecha y hora de fin */
endDate?: string
/** Descripción del evento */
description?: string
/** Ubicación del evento */
location?: EventLocationInfo
}
/**
* Información de mensaje citado (quoted/reply)
*/
@@ -139,6 +184,10 @@ export interface Message {
location?: LocationInfo
/** Información de contacto */
contact?: ContactInfo
/** Información de encuesta */
poll?: PollInfo
/** Información de evento */
event?: EventInfo
/** Mensaje citado */
quoted?: QuotedMessage
/** Reacciones al mensaje */
@@ -304,6 +353,7 @@ export function getMessageTypePlaceholder(type: MessageType): string {
location: 'Ubicación',
reaction: 'Reacción',
poll: 'Encuesta',
event: 'Evento',
unknown: 'Mensaje'
}
return placeholders[type] || 'Mensaje'
@@ -324,6 +374,7 @@ export function getMessageTypeIcon(type: MessageType): string {
location: 'i-lucide-map-pin',
reaction: 'i-lucide-heart',
poll: 'i-lucide-bar-chart',
event: 'i-lucide-calendar',
unknown: 'i-lucide-help-circle'
}
return icons[type] || 'i-lucide-message-square'