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
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:
150
app/components/messages/ContactSendModal.vue
Normal file
150
app/components/messages/ContactSendModal.vue
Normal 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>
|
||||||
257
app/components/messages/EventSendModal.vue
Normal file
257
app/components/messages/EventSendModal.vue
Normal 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>
|
||||||
@@ -135,6 +135,20 @@
|
|||||||
:from-me="message.fromMe"
|
: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 -->
|
<!-- Unknown/unsupported type -->
|
||||||
<div
|
<div
|
||||||
v-else-if="message.type === 'unknown' || !message.content"
|
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 MessageSticker from './content/MessageSticker.vue'
|
||||||
import MessageContact from './content/MessageContact.vue'
|
import MessageContact from './content/MessageContact.vue'
|
||||||
import MessageLocation from './content/MessageLocation.vue'
|
import MessageLocation from './content/MessageLocation.vue'
|
||||||
|
import MessagePoll from './content/MessagePoll.vue'
|
||||||
|
import MessageEvent from './content/MessageEvent.vue'
|
||||||
import ReactionPicker from './ReactionPicker.vue'
|
import ReactionPicker from './ReactionPicker.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -174,6 +174,24 @@
|
|||||||
<p class="text-white font-medium">Suelta los archivos aquí</p>
|
<p class="text-white font-medium">Suelta los archivos aquí</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -190,9 +208,37 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
replyingTo: null
|
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<{
|
const emit = defineEmits<{
|
||||||
send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
|
send: [content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
|
||||||
sendVoice: [audioFile: File]
|
sendVoice: [audioFile: File]
|
||||||
|
sendContact: [contacts: ContactInfo[], quotedId?: string]
|
||||||
|
sendPoll: [poll: PollData, quotedId?: string]
|
||||||
|
sendEvent: [event: EventData, quotedId?: string]
|
||||||
cancelReply: []
|
cancelReply: []
|
||||||
typing: []
|
typing: []
|
||||||
recording: [isRecording: boolean]
|
recording: [isRecording: boolean]
|
||||||
@@ -212,6 +258,11 @@ const isDragging = ref(false)
|
|||||||
const stickerModes = ref<boolean[]>([])
|
const stickerModes = ref<boolean[]>([])
|
||||||
const showDebug = ref(false)
|
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
|
// File size limits (in bytes) - should match server
|
||||||
const MAX_SIZES: Record<string, number> = {
|
const MAX_SIZES: Record<string, number> = {
|
||||||
image: 16 * 1024 * 1024, // 16 MB
|
image: 16 * 1024 * 1024, // 16 MB
|
||||||
@@ -317,6 +368,21 @@ const attachmentMenuItems = [
|
|||||||
label: 'Documento',
|
label: 'Documento',
|
||||||
icon: 'i-lucide-file',
|
icon: 'i-lucide-file',
|
||||||
onSelect: () => documentInput.value?.click()
|
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 => {
|
const getTypePlaceholder = (type: MessageType): string => {
|
||||||
return getMessageTypePlaceholder(type)
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
201
app/components/messages/PollSendModal.vue
Normal file
201
app/components/messages/PollSendModal.vue
Normal 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>
|
||||||
126
app/components/messages/content/MessageEvent.vue
Normal file
126
app/components/messages/content/MessageEvent.vue
Normal 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>
|
||||||
108
app/components/messages/content/MessagePoll.vue
Normal file
108
app/components/messages/content/MessagePoll.vue
Normal 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>
|
||||||
@@ -231,6 +231,9 @@
|
|||||||
:replying-to="replyingTo"
|
:replying-to="replyingTo"
|
||||||
@send="handleSendMessage"
|
@send="handleSendMessage"
|
||||||
@send-voice="handleSendVoice"
|
@send-voice="handleSendVoice"
|
||||||
|
@send-contact="handleSendContact"
|
||||||
|
@send-poll="handleSendPoll"
|
||||||
|
@send-event="handleSendEvent"
|
||||||
@cancel-reply="replyingTo = null"
|
@cancel-reply="replyingTo = null"
|
||||||
@typing="handleTyping"
|
@typing="handleTyping"
|
||||||
@recording="handleRecordingPresence"
|
@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
|
// Reload chats for current instance
|
||||||
const reloadChats = async () => {
|
const reloadChats = async () => {
|
||||||
if (!selectedInstance.value?.value) return
|
if (!selectedInstance.value?.value) return
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type MessageType =
|
|||||||
| 'location'
|
| 'location'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'poll'
|
| 'poll'
|
||||||
|
| 'event'
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
|
|
||||||
// Estados de mensaje
|
// Estados de mensaje
|
||||||
@@ -79,6 +80,50 @@ export interface ContactInfo {
|
|||||||
phones?: string[]
|
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)
|
* Información de mensaje citado (quoted/reply)
|
||||||
*/
|
*/
|
||||||
@@ -139,6 +184,10 @@ export interface Message {
|
|||||||
location?: LocationInfo
|
location?: LocationInfo
|
||||||
/** Información de contacto */
|
/** Información de contacto */
|
||||||
contact?: ContactInfo
|
contact?: ContactInfo
|
||||||
|
/** Información de encuesta */
|
||||||
|
poll?: PollInfo
|
||||||
|
/** Información de evento */
|
||||||
|
event?: EventInfo
|
||||||
/** Mensaje citado */
|
/** Mensaje citado */
|
||||||
quoted?: QuotedMessage
|
quoted?: QuotedMessage
|
||||||
/** Reacciones al mensaje */
|
/** Reacciones al mensaje */
|
||||||
@@ -304,6 +353,7 @@ export function getMessageTypePlaceholder(type: MessageType): string {
|
|||||||
location: 'Ubicación',
|
location: 'Ubicación',
|
||||||
reaction: 'Reacción',
|
reaction: 'Reacción',
|
||||||
poll: 'Encuesta',
|
poll: 'Encuesta',
|
||||||
|
event: 'Evento',
|
||||||
unknown: 'Mensaje'
|
unknown: 'Mensaje'
|
||||||
}
|
}
|
||||||
return placeholders[type] || 'Mensaje'
|
return placeholders[type] || 'Mensaje'
|
||||||
@@ -324,6 +374,7 @@ export function getMessageTypeIcon(type: MessageType): string {
|
|||||||
location: 'i-lucide-map-pin',
|
location: 'i-lucide-map-pin',
|
||||||
reaction: 'i-lucide-heart',
|
reaction: 'i-lucide-heart',
|
||||||
poll: 'i-lucide-bar-chart',
|
poll: 'i-lucide-bar-chart',
|
||||||
|
event: 'i-lucide-calendar',
|
||||||
unknown: 'i-lucide-help-circle'
|
unknown: 'i-lucide-help-circle'
|
||||||
}
|
}
|
||||||
return icons[type] || 'i-lucide-message-square'
|
return icons[type] || 'i-lucide-message-square'
|
||||||
|
|||||||
@@ -33,6 +33,43 @@ interface TextMessageBody {
|
|||||||
quotedMessageId?: string
|
quotedMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContactInfo {
|
||||||
|
displayName: string
|
||||||
|
phoneNumber: string
|
||||||
|
organization?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactMessageBody {
|
||||||
|
type: 'contact'
|
||||||
|
contacts: ContactInfo[]
|
||||||
|
quotedMessageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PollMessageBody {
|
||||||
|
type: 'poll'
|
||||||
|
name: string
|
||||||
|
options: string[]
|
||||||
|
selectableCount?: number
|
||||||
|
quotedMessageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventMessageBody {
|
||||||
|
type: 'event'
|
||||||
|
name: string
|
||||||
|
startDate: string // ISO date string
|
||||||
|
endDate?: string
|
||||||
|
description?: string
|
||||||
|
location?: {
|
||||||
|
name?: string
|
||||||
|
address?: string
|
||||||
|
latitude?: number
|
||||||
|
longitude?: number
|
||||||
|
}
|
||||||
|
quotedMessageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonMessageBody = TextMessageBody | ContactMessageBody | PollMessageBody | EventMessageBody
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const username = getHeader(event, 'x-authentik-username')
|
const username = getHeader(event, 'x-authentik-username')
|
||||||
if (!username) {
|
if (!username) {
|
||||||
@@ -71,47 +108,73 @@ export default defineEventHandler(async (event) => {
|
|||||||
// ==================== MEDIA / STICKER ====================
|
// ==================== MEDIA / STICKER ====================
|
||||||
return await handleMediaMessage(event, instanceId, chatId, jid, socket)
|
return await handleMediaMessage(event, instanceId, chatId, jid, socket)
|
||||||
} else {
|
} else {
|
||||||
// ==================== TEXT ====================
|
// ==================== JSON MESSAGES ====================
|
||||||
return await handleTextMessage(event, instanceId, chatId, jid)
|
return await handleJsonMessage(event, instanceId, chatId, jid, socket)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle text message sending
|
* Handle JSON message sending (text, contacts, polls, events)
|
||||||
*/
|
*/
|
||||||
async function handleTextMessage(
|
async function handleJsonMessage(
|
||||||
event: any,
|
event: any,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
jid: string
|
jid: string,
|
||||||
|
socket: any
|
||||||
) {
|
) {
|
||||||
const body = await readBody<TextMessageBody>(event)
|
const body = await readBody<JsonMessageBody>(event)
|
||||||
const messageText = body.content || body.message
|
|
||||||
|
|
||||||
if (!messageText?.trim()) {
|
// Determine message type
|
||||||
throw createError({ statusCode: 400, message: 'Message content is required' })
|
const messageType = (body as any).type || 'text'
|
||||||
}
|
|
||||||
|
|
||||||
// Get quoted message if provided
|
// Get quoted message if provided
|
||||||
let quotedMessage = null
|
let quotedMessage = null
|
||||||
if (body.quotedMessageId) {
|
const quotedMessageId = (body as any).quotedMessageId
|
||||||
|
if (quotedMessageId) {
|
||||||
const quotedResult = await query(
|
const quotedResult = await query(
|
||||||
'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2',
|
'SELECT raw_message FROM messages WHERE message_id = $1 AND instance_id = $2',
|
||||||
[body.quotedMessageId, instanceId]
|
[quotedMessageId, instanceId]
|
||||||
)
|
)
|
||||||
if (quotedResult.rows.length > 0) {
|
if (quotedResult.rows.length > 0) {
|
||||||
quotedMessage = quotedResult.rows[0].raw_message
|
quotedMessage = quotedResult.rows[0].raw_message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const content = { text: messageText }
|
|
||||||
const options: any = {}
|
const options: any = {}
|
||||||
if (quotedMessage) {
|
if (quotedMessage) {
|
||||||
options.quoted = quotedMessage
|
options.quoted = quotedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await baileysManager.sendMessage(instanceId, jid, content, options)
|
try {
|
||||||
|
let content: any
|
||||||
|
let dbMessageType: string
|
||||||
|
let dbContent: string
|
||||||
|
|
||||||
|
switch (messageType) {
|
||||||
|
case 'contact':
|
||||||
|
return await handleContactMessage(body as ContactMessageBody, instanceId, chatId, jid, socket, options)
|
||||||
|
|
||||||
|
case 'poll':
|
||||||
|
return await handlePollMessage(body as PollMessageBody, instanceId, chatId, jid, socket, options)
|
||||||
|
|
||||||
|
case 'event':
|
||||||
|
return await handleEventMessage(body as EventMessageBody, instanceId, chatId, jid, socket, options)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Text message
|
||||||
|
const textBody = body as TextMessageBody
|
||||||
|
const messageText = textBody.content || textBody.message
|
||||||
|
|
||||||
|
if (!messageText?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Message content is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
content = { text: messageText }
|
||||||
|
dbMessageType = 'text'
|
||||||
|
dbContent = messageText
|
||||||
|
|
||||||
|
const result = await socket.sendMessage(jid, content, options)
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
await query(
|
await query(
|
||||||
@@ -126,8 +189,91 @@ async function handleTextMessage(
|
|||||||
result.key.id,
|
result.key.id,
|
||||||
'me',
|
'me',
|
||||||
true,
|
true,
|
||||||
'text',
|
dbMessageType,
|
||||||
messageText,
|
dbContent,
|
||||||
|
'sent',
|
||||||
|
JSON.stringify(result),
|
||||||
|
quotedMessageId || null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE chats SET last_message_at = NOW(), last_message_type = $1 WHERE id = $2`,
|
||||||
|
[dbMessageType, chatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: [{ messageId: result.key.id, type: dbMessageType }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Send] Error sending message:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: `Failed to send message: ${(error as Error).message}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle contact message sending
|
||||||
|
*/
|
||||||
|
async function handleContactMessage(
|
||||||
|
body: ContactMessageBody,
|
||||||
|
instanceId: string,
|
||||||
|
chatId: string,
|
||||||
|
jid: string,
|
||||||
|
socket: any,
|
||||||
|
options: any
|
||||||
|
) {
|
||||||
|
if (!body.contacts || body.contacts.length === 0) {
|
||||||
|
throw createError({ statusCode: 400, message: 'At least one contact is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build vCards for each contact
|
||||||
|
const vcards = body.contacts.map(contact => {
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCARD',
|
||||||
|
'VERSION:3.0',
|
||||||
|
`FN:${contact.displayName}`,
|
||||||
|
`TEL;type=CELL;waid=${contact.phoneNumber.replace(/\D/g, '')}:${contact.phoneNumber}`
|
||||||
|
]
|
||||||
|
|
||||||
|
if (contact.organization) {
|
||||||
|
lines.push(`ORG:${contact.organization}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VCARD')
|
||||||
|
return lines.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
contacts: {
|
||||||
|
displayName: body.contacts.length === 1
|
||||||
|
? body.contacts[0].displayName
|
||||||
|
: `${body.contacts.length} contactos`,
|
||||||
|
contacts: vcards.map(vcard => ({ vcard }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await socket.sendMessage(jid, content, options)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await query(
|
||||||
|
`INSERT INTO messages (
|
||||||
|
instance_id, chat_id, message_id, from_jid, from_me,
|
||||||
|
message_type, content, timestamp, status, raw_message, quoted_message_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)
|
||||||
|
ON CONFLICT (instance_id, message_id) DO NOTHING`,
|
||||||
|
[
|
||||||
|
instanceId,
|
||||||
|
chatId,
|
||||||
|
result.key.id,
|
||||||
|
'me',
|
||||||
|
true,
|
||||||
|
'contact',
|
||||||
|
JSON.stringify(body.contacts),
|
||||||
'sent',
|
'sent',
|
||||||
JSON.stringify(result),
|
JSON.stringify(result),
|
||||||
body.quotedMessageId || null
|
body.quotedMessageId || null
|
||||||
@@ -135,20 +281,163 @@ async function handleTextMessage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE chats SET last_message_at = NOW(), last_message_type = 'text' WHERE id = $1`,
|
`UPDATE chats SET last_message_at = NOW(), last_message_type = 'contact' WHERE id = $1`,
|
||||||
[chatId]
|
[chatId]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
messages: [{ messageId: result.key.id, type: 'text' }]
|
messages: [{ messageId: result.key.id, type: 'contact' }]
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.error('[Send] Error sending text:', error)
|
|
||||||
throw createError({
|
/**
|
||||||
statusCode: 500,
|
* Handle poll message sending
|
||||||
message: `Failed to send message: ${(error as Error).message}`
|
*/
|
||||||
})
|
async function handlePollMessage(
|
||||||
|
body: PollMessageBody,
|
||||||
|
instanceId: string,
|
||||||
|
chatId: string,
|
||||||
|
jid: string,
|
||||||
|
socket: any,
|
||||||
|
options: any
|
||||||
|
) {
|
||||||
|
if (!body.name?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Poll name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.options || body.options.length < 2) {
|
||||||
|
throw createError({ statusCode: 400, message: 'At least 2 poll options are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.options.length > 12) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Maximum 12 poll options allowed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
poll: {
|
||||||
|
name: body.name,
|
||||||
|
values: body.options,
|
||||||
|
selectableCount: body.selectableCount || 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await socket.sendMessage(jid, content, options)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await query(
|
||||||
|
`INSERT INTO messages (
|
||||||
|
instance_id, chat_id, message_id, from_jid, from_me,
|
||||||
|
message_type, content, timestamp, status, raw_message, quoted_message_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)
|
||||||
|
ON CONFLICT (instance_id, message_id) DO NOTHING`,
|
||||||
|
[
|
||||||
|
instanceId,
|
||||||
|
chatId,
|
||||||
|
result.key.id,
|
||||||
|
'me',
|
||||||
|
true,
|
||||||
|
'poll',
|
||||||
|
JSON.stringify({ name: body.name, options: body.options, selectableCount: body.selectableCount || 1 }),
|
||||||
|
'sent',
|
||||||
|
JSON.stringify(result),
|
||||||
|
body.quotedMessageId || null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE chats SET last_message_at = NOW(), last_message_type = 'poll' WHERE id = $1`,
|
||||||
|
[chatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: [{ messageId: result.key.id, type: 'poll' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle event message sending
|
||||||
|
*/
|
||||||
|
async function handleEventMessage(
|
||||||
|
body: EventMessageBody,
|
||||||
|
instanceId: string,
|
||||||
|
chatId: string,
|
||||||
|
jid: string,
|
||||||
|
socket: any,
|
||||||
|
options: any
|
||||||
|
) {
|
||||||
|
if (!body.name?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Event name is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.startDate) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Event start date is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventContent: any = {
|
||||||
|
name: body.name,
|
||||||
|
startDate: new Date(body.startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.endDate) {
|
||||||
|
eventContent.endDate = new Date(body.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.description) {
|
||||||
|
eventContent.description = body.description
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.location) {
|
||||||
|
if (body.location.latitude && body.location.longitude) {
|
||||||
|
eventContent.location = {
|
||||||
|
degreesLatitude: body.location.latitude,
|
||||||
|
degreesLongitude: body.location.longitude,
|
||||||
|
name: body.location.name,
|
||||||
|
address: body.location.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = { event: eventContent }
|
||||||
|
|
||||||
|
const result = await socket.sendMessage(jid, content, options)
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
await query(
|
||||||
|
`INSERT INTO messages (
|
||||||
|
instance_id, chat_id, message_id, from_jid, from_me,
|
||||||
|
message_type, content, timestamp, status, raw_message, quoted_message_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)
|
||||||
|
ON CONFLICT (instance_id, message_id) DO NOTHING`,
|
||||||
|
[
|
||||||
|
instanceId,
|
||||||
|
chatId,
|
||||||
|
result.key.id,
|
||||||
|
'me',
|
||||||
|
true,
|
||||||
|
'event',
|
||||||
|
JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
startDate: body.startDate,
|
||||||
|
endDate: body.endDate,
|
||||||
|
description: body.description,
|
||||||
|
location: body.location
|
||||||
|
}),
|
||||||
|
'sent',
|
||||||
|
JSON.stringify(result),
|
||||||
|
body.quotedMessageId || null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE chats SET last_message_at = NOW(), last_message_type = 'event' WHERE id = $1`,
|
||||||
|
[chatId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: [{ messageId: result.key.id, type: 'event' }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user