feat: WhatsApp Nucleo con Nuxt 4 + Baileys v7
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 6m46s
Reemplazo completo de Evolution API por implementación directa con Baileys. Características: - Dashboard completo con Nuxt UI v4 - Soporte para múltiples instancias de WhatsApp - Conexión via QR code o pairing code - Persistencia de mensajes en PostgreSQL - API REST para integraciones externas - Webhooks con firma HMAC - SSE para actualizaciones en tiempo real - Autenticación con Authentik
This commit is contained in:
59
app/components/app/AppSidebar.vue
Normal file
59
app/components/app/AppSidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<UDashboardSidebar class="bg-[var(--wa-surface)] border-[var(--wa-border)]">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3 p-4">
|
||||
<div class="w-10 h-10 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
|
||||
<UIcon name="i-lucide-message-circle" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-[var(--wa-text)]">WhatsApp Nucleo</span>
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">Multi-Instance Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<UDashboardSidebarContent>
|
||||
<UDashboardSidebarGroup>
|
||||
<UDashboardSidebarItem
|
||||
to="/"
|
||||
icon="i-lucide-layout-dashboard"
|
||||
label="Dashboard"
|
||||
exact
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/instances"
|
||||
icon="i-lucide-smartphone"
|
||||
label="Instancias"
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/messages"
|
||||
icon="i-lucide-message-square"
|
||||
label="Mensajes"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
|
||||
<UDashboardSidebarGroup label="Integraciones">
|
||||
<UDashboardSidebarItem
|
||||
to="/webhooks"
|
||||
icon="i-lucide-webhook"
|
||||
label="Webhooks"
|
||||
/>
|
||||
<UDashboardSidebarItem
|
||||
to="/api-docs"
|
||||
icon="i-lucide-code"
|
||||
label="API Docs"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
|
||||
<UDashboardSidebarGroup label="Sistema">
|
||||
<UDashboardSidebarItem
|
||||
to="/settings"
|
||||
icon="i-lucide-settings"
|
||||
label="Configuracion"
|
||||
/>
|
||||
</UDashboardSidebarGroup>
|
||||
</UDashboardSidebarContent>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
</template>
|
||||
24
app/components/app/ConnectionStatus.vue
Normal file
24
app/components/app/ConnectionStatus.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-3 py-1 rounded-full bg-[var(--wa-surface)] border border-[var(--wa-border)]">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="statusClass"
|
||||
/>
|
||||
<span class="text-sm text-[var(--wa-text-muted)]">
|
||||
{{ connectedCount }} / {{ totalCount }} conectadas
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Conectar con useInstances cuando esté implementado
|
||||
const connectedCount = ref(0)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (totalCount.value === 0) return 'bg-gray-500'
|
||||
if (connectedCount.value === totalCount.value) return 'bg-[var(--wa-green-light)]'
|
||||
if (connectedCount.value === 0) return 'bg-red-500'
|
||||
return 'bg-yellow-500'
|
||||
})
|
||||
</script>
|
||||
36
app/components/app/UserMenu.vue
Normal file
36
app/components/app/UserMenu.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<UDropdownMenu v-if="user">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
class="rounded-full p-0"
|
||||
>
|
||||
<UAvatar
|
||||
:src="user.avatar"
|
||||
:alt="user.name || user.username"
|
||||
size="sm"
|
||||
/>
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="px-3 py-2 border-b border-[var(--wa-border)]">
|
||||
<p class="font-medium text-[var(--wa-text)]">{{ user.name || user.username }}</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<UDropdownMenuItem
|
||||
icon="i-lucide-user"
|
||||
label="Mi Perfil"
|
||||
@click="goToProfile"
|
||||
/>
|
||||
<UDropdownMenuItem
|
||||
icon="i-lucide-log-out"
|
||||
label="Cerrar Sesion"
|
||||
@click="logout"
|
||||
/>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { user, logout, goToProfile } = useAuthentik()
|
||||
</script>
|
||||
54
app/components/common/MetricCard.vue
Normal file
54
app/components/common/MetricCard.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<UIcon :name="icon" class="w-5 h-5" :class="iconClass" />
|
||||
</div>
|
||||
<div v-if="total" class="text-sm text-[var(--wa-text-muted)]">
|
||||
de {{ total }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-2xl font-bold text-[var(--wa-text)]">{{ value }}</div>
|
||||
<div class="text-sm text-[var(--wa-text-muted)]">{{ title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
value: number | string
|
||||
total?: number
|
||||
icon: string
|
||||
color?: 'green' | 'blue' | 'purple' | 'amber' | 'red'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'green'
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const classes: Record<string, string> = {
|
||||
green: 'bg-green-500/20',
|
||||
blue: 'bg-blue-500/20',
|
||||
purple: 'bg-purple-500/20',
|
||||
amber: 'bg-amber-500/20',
|
||||
red: 'bg-red-500/20'
|
||||
}
|
||||
return classes[props.color]
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
const classes: Record<string, string> = {
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
purple: 'text-purple-500',
|
||||
amber: 'text-amber-500',
|
||||
red: 'text-red-500'
|
||||
}
|
||||
return classes[props.color]
|
||||
})
|
||||
</script>
|
||||
42
app/components/common/StatusBadge.vue
Normal file
42
app/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusConfig: Record<string, { label: string; class: string }> = {
|
||||
connected: {
|
||||
label: 'Conectado',
|
||||
class: 'bg-green-500/20 text-green-400'
|
||||
},
|
||||
disconnected: {
|
||||
label: 'Desconectado',
|
||||
class: 'bg-red-500/20 text-red-400'
|
||||
},
|
||||
connecting: {
|
||||
label: 'Conectando...',
|
||||
class: 'bg-blue-500/20 text-blue-400'
|
||||
},
|
||||
qr_ready: {
|
||||
label: 'Escanear QR',
|
||||
class: 'bg-amber-500/20 text-amber-400'
|
||||
},
|
||||
pairing: {
|
||||
label: 'Emparejando...',
|
||||
class: 'bg-purple-500/20 text-purple-400'
|
||||
}
|
||||
}
|
||||
|
||||
const label = computed(() => statusConfig[props.status]?.label || props.status)
|
||||
const badgeClass = computed(() => statusConfig[props.status]?.class || 'bg-gray-500/20 text-gray-400')
|
||||
</script>
|
||||
91
app/components/instances/CreateInstanceModal.vue
Normal file
91
app/components/instances/CreateInstanceModal.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<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-[var(--wa-text)]">Nueva Instancia</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre de la instancia" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Ej: Ventas, Soporte, Marketing..."
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="text-sm text-[var(--wa-text-muted)]">
|
||||
<p>Despues de crear la instancia, podras conectarla escaneando un codigo QR con WhatsApp.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Cancelar
|
||||
</UButton>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!form.name.trim()"
|
||||
>
|
||||
Crear Instancia
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
created: [instance: any]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name.trim()) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const instance = await $fetch('/api/instances', {
|
||||
method: 'POST',
|
||||
body: { name: form.value.name }
|
||||
})
|
||||
|
||||
emit('created', instance)
|
||||
form.value.name = ''
|
||||
} catch (error) {
|
||||
console.error('Error creating instance:', error)
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
117
app/components/instances/InstanceCard.vue
Normal file
117
app/components/instances/InstanceCard.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--wa-green-dark)] flex items-center justify-center">
|
||||
<UIcon name="i-lucide-smartphone" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--wa-text)]">{{ instance.name }}</h3>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">
|
||||
{{ instance.phoneNumber || 'Sin numero asignado' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge :status="instance.status" />
|
||||
</div>
|
||||
|
||||
<!-- Connection info -->
|
||||
<div v-if="instance.lastConnectedAt" class="text-xs text-[var(--wa-text-muted)] mb-4">
|
||||
Ultima conexion: {{ formatDate(instance.lastConnectedAt) }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="instance.status === 'disconnected'">
|
||||
<UButton
|
||||
size="sm"
|
||||
icon="i-lucide-qr-code"
|
||||
@click="$emit('connect', instance.id)"
|
||||
>
|
||||
Conectar
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.status === 'connected'">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
icon="i-lucide-message-square"
|
||||
:to="`/messages?instance=${instance.id}`"
|
||||
>
|
||||
Mensajes
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-unplug"
|
||||
@click="$emit('disconnect', instance.id)"
|
||||
>
|
||||
Desconectar
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else-if="instance.status === 'qr_ready'">
|
||||
<UButton
|
||||
size="sm"
|
||||
icon="i-lucide-qr-code"
|
||||
@click="$emit('connect', instance.id)"
|
||||
>
|
||||
Ver QR
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading
|
||||
>
|
||||
Procesando...
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<!-- Delete button -->
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-trash-2"
|
||||
class="ml-auto"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Instance {
|
||||
id: string
|
||||
name: string
|
||||
phoneNumber: string | null
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'qr_ready' | 'pairing'
|
||||
lastConnectedAt: Date | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instance: Instance
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
connect: [instanceId: string]
|
||||
disconnect: [instanceId: string]
|
||||
delete: [instanceId: string]
|
||||
}>()
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleString('es-AR')
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
// TODO: Show confirmation modal
|
||||
emit('delete', props.instance.id)
|
||||
}
|
||||
</script>
|
||||
111
app/components/instances/QRCodeModal.vue
Normal file
111
app/components/instances/QRCodeModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<UModal v-model:open="isOpen" :ui="{ width: 'max-w-md' }">
|
||||
<template #content>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-[var(--wa-text)]">Escanear Codigo QR</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="text-center">
|
||||
<!-- QR Code Display -->
|
||||
<div v-if="qrCode" class="mb-4">
|
||||
<div class="bg-white p-4 rounded-lg inline-block">
|
||||
<img :src="qrCode" alt="QR Code" class="w-64 h-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-4 py-16">
|
||||
<UIcon
|
||||
name="i-lucide-loader-2"
|
||||
class="w-12 h-12 text-[var(--wa-text-muted)] animate-spin mx-auto"
|
||||
/>
|
||||
<p class="text-[var(--wa-text-muted)] mt-4">Generando codigo QR...</p>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="text-sm text-[var(--wa-text-muted)] space-y-2">
|
||||
<p>1. Abre WhatsApp en tu telefono</p>
|
||||
<p>2. Ve a Configuracion > Dispositivos vinculados</p>
|
||||
<p>3. Toca "Vincular un dispositivo"</p>
|
||||
<p>4. Escanea este codigo QR</p>
|
||||
</div>
|
||||
|
||||
<!-- Alternative: Pairing Code -->
|
||||
<div class="mt-6 pt-6 border-t border-[var(--wa-border)]">
|
||||
<p class="text-sm text-[var(--wa-text-muted)] mb-3">
|
||||
O usa un codigo de emparejamiento
|
||||
</p>
|
||||
<UButton
|
||||
variant="soft"
|
||||
@click="requestPairingCode"
|
||||
:loading="loadingPairing"
|
||||
>
|
||||
Obtener Codigo
|
||||
</UButton>
|
||||
|
||||
<div v-if="pairingCode" class="mt-4">
|
||||
<p class="text-2xl font-mono font-bold text-[var(--wa-green-light)] tracking-widest">
|
||||
{{ pairingCode }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--wa-text-muted)] mt-2">
|
||||
Ingresa este codigo en WhatsApp > Dispositivos vinculados > Vincular con numero de telefono
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
open: boolean
|
||||
instanceId: string | null
|
||||
qrCode: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loadingPairing = ref(false)
|
||||
const pairingCode = ref<string | null>(null)
|
||||
|
||||
const requestPairingCode = async () => {
|
||||
if (!props.instanceId) return
|
||||
|
||||
loadingPairing.value = true
|
||||
try {
|
||||
const response = await $fetch<{ code: string }>(`/api/instances/${props.instanceId}/pairing-code`, {
|
||||
method: 'POST'
|
||||
})
|
||||
pairingCode.value = response.code
|
||||
} catch (error) {
|
||||
console.error('Error requesting pairing code:', error)
|
||||
} finally {
|
||||
loadingPairing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset pairing code when modal closes
|
||||
watch(isOpen, (value) => {
|
||||
if (!value) {
|
||||
pairingCode.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
68
app/components/messages/ChatItem.vue
Normal file
68
app/components/messages/ChatItem.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 cursor-pointer transition-colors hover:bg-[var(--wa-bg-light)]"
|
||||
:class="{ 'bg-[var(--wa-bg-light)]': active }"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<UAvatar
|
||||
:alt="chat.name"
|
||||
size="md"
|
||||
:src="chat.profilePicture"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-[var(--wa-text)] truncate">{{ chat.name }}</p>
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">{{ formatTime(chat.lastMessageAt) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate">{{ chat.lastMessage }}</p>
|
||||
<span
|
||||
v-if="chat.unreadCount > 0"
|
||||
class="bg-[var(--wa-green-light)] text-white text-xs rounded-full px-2 py-0.5"
|
||||
>
|
||||
{{ chat.unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Chat {
|
||||
id: string
|
||||
name: string
|
||||
jid: string
|
||||
profilePicture?: string
|
||||
lastMessage: string
|
||||
lastMessageAt: Date
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
return d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (days === 1) {
|
||||
return 'Ayer'
|
||||
} else if (days < 7) {
|
||||
return d.toLocaleDateString('es-AR', { weekday: 'short' })
|
||||
} else {
|
||||
return d.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
app/components/messages/MessageBubble.vue
Normal file
85
app/components/messages/MessageBubble.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex"
|
||||
:class="message.fromMe ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[70%] rounded-lg px-3 py-2"
|
||||
:class="message.fromMe ? 'bubble-out' : 'bubble-in'"
|
||||
>
|
||||
<!-- Message content -->
|
||||
<p class="text-[var(--wa-text)] whitespace-pre-wrap break-words">{{ message.content }}</p>
|
||||
|
||||
<!-- Image -->
|
||||
<img
|
||||
v-if="message.mediaUrl && message.type === 'image'"
|
||||
:src="message.mediaUrl"
|
||||
class="rounded-lg max-w-full mt-2"
|
||||
/>
|
||||
|
||||
<!-- Caption for media -->
|
||||
<p
|
||||
v-if="message.caption"
|
||||
class="text-[var(--wa-text)] mt-2"
|
||||
>
|
||||
{{ message.caption }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-1 mt-1">
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
<UIcon
|
||||
v-if="message.fromMe"
|
||||
:name="statusIcon"
|
||||
class="w-4 h-4"
|
||||
:class="statusColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Message {
|
||||
id: string
|
||||
content: string
|
||||
type: 'text' | 'image' | 'video' | 'document' | 'audio'
|
||||
mediaUrl?: string
|
||||
caption?: string
|
||||
fromMe: boolean
|
||||
timestamp: Date
|
||||
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
pending: 'i-lucide-clock',
|
||||
sent: 'i-lucide-check',
|
||||
delivered: 'i-lucide-check-check',
|
||||
read: 'i-lucide-check-check',
|
||||
failed: 'i-lucide-alert-circle'
|
||||
}
|
||||
return icons[props.message.status] || 'i-lucide-check'
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
|
||||
if (props.message.status === 'failed') return 'text-red-500'
|
||||
return 'text-[var(--wa-text-muted)]'
|
||||
})
|
||||
</script>
|
||||
45
app/components/messages/MessageInput.vue
Normal file
45
app/components/messages/MessageInput.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Attachment button -->
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-paperclip"
|
||||
class="text-[var(--wa-text-muted)]"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
v-model="message"
|
||||
placeholder="Escribe un mensaje..."
|
||||
:rows="1"
|
||||
autoresize
|
||||
:maxrows="5"
|
||||
class="bg-[var(--wa-bg-light)]"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<UButton
|
||||
:icon="message.trim() ? 'i-lucide-send' : 'i-lucide-mic'"
|
||||
:disabled="!message.trim()"
|
||||
@click="handleSend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
send: [content: string]
|
||||
}>()
|
||||
|
||||
const message = ref('')
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.value.trim()) return
|
||||
|
||||
emit('send', message.value)
|
||||
message.value = ''
|
||||
}
|
||||
</script>
|
||||
107
app/components/webhooks/WebhookCard.vue
Normal file
107
app/components/webhooks/WebhookCard.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="instance-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
:class="webhook.isActive ? 'bg-green-500/20' : 'bg-gray-500/20'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-webhook"
|
||||
class="w-5 h-5"
|
||||
:class="webhook.isActive ? 'text-green-500' : 'text-gray-500'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[var(--wa-text)]">{{ webhook.name }}</h3>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate max-w-md">{{ webhook.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<USwitch
|
||||
:model-value="webhook.isActive"
|
||||
@update:model-value="$emit('toggle', webhook.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)] mb-2">Eventos:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="event in webhook.events"
|
||||
:key="event"
|
||||
class="bg-[var(--wa-bg-light)] text-[var(--wa-text-muted)] text-xs px-2 py-1 rounded"
|
||||
>
|
||||
{{ event }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instance -->
|
||||
<div v-if="webhook.instanceId" class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)]">
|
||||
Instancia: <span class="text-[var(--wa-text)]">{{ webhook.instanceName || webhook.instanceId }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="mb-4">
|
||||
<p class="text-xs text-[var(--wa-text-muted)]">
|
||||
Aplica a: <span class="text-[var(--wa-green-light)]">Todas las instancias</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="soft"
|
||||
icon="i-lucide-play"
|
||||
@click="$emit('test', webhook.id)"
|
||||
>
|
||||
Probar
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="i-lucide-pencil"
|
||||
@click="$emit('edit', webhook)"
|
||||
>
|
||||
Editar
|
||||
</UButton>
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="red"
|
||||
icon="i-lucide-trash-2"
|
||||
@click="$emit('delete', webhook.id)"
|
||||
>
|
||||
Eliminar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
events: string[]
|
||||
isActive: boolean
|
||||
instanceId?: string
|
||||
instanceName?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
webhook: Webhook
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [webhook: Webhook]
|
||||
delete: [webhookId: string]
|
||||
test: [webhookId: string]
|
||||
toggle: [webhookId: string, active: boolean]
|
||||
}>()
|
||||
</script>
|
||||
182
app/components/webhooks/WebhookFormModal.vue
Normal file
182
app/components/webhooks/WebhookFormModal.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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-[var(--wa-text)]">
|
||||
{{ webhook ? 'Editar Webhook' : 'Nuevo Webhook' }}
|
||||
</h3>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="Ej: Notificaciones a n8n"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="URL" required>
|
||||
<UInput
|
||||
v-model="form.url"
|
||||
placeholder="https://..."
|
||||
type="url"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Secret (opcional)">
|
||||
<UInput
|
||||
v-model="form.secret"
|
||||
placeholder="Para firmar las peticiones con HMAC"
|
||||
type="password"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Eventos">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<UCheckbox
|
||||
v-for="event in availableEvents"
|
||||
:key="event.value"
|
||||
v-model="form.events"
|
||||
:value="event.value"
|
||||
:label="event.label"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Instancia">
|
||||
<USelectMenu
|
||||
v-model="form.instanceId"
|
||||
:items="instanceOptions"
|
||||
placeholder="Todas las instancias"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<UButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Cancelar
|
||||
</UButton>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!isValid"
|
||||
>
|
||||
{{ webhook ? 'Guardar Cambios' : 'Crear Webhook' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Webhook {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
secret?: string
|
||||
events: string[]
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
webhook?: Webhook | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [webhook: any]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
events: [] as string[],
|
||||
instanceId: null as string | null
|
||||
})
|
||||
|
||||
const availableEvents = [
|
||||
{ value: 'message.received', label: 'Mensaje recibido' },
|
||||
{ value: 'message.sent', label: 'Mensaje enviado' },
|
||||
{ value: 'message.status', label: 'Estado de mensaje' },
|
||||
{ value: 'instance.connected', label: 'Instancia conectada' },
|
||||
{ value: 'instance.disconnected', label: 'Instancia desconectada' },
|
||||
{ value: 'instance.qr', label: 'QR disponible' }
|
||||
]
|
||||
|
||||
// TODO: Cargar instancias reales
|
||||
const instanceOptions = ref<any[]>([])
|
||||
|
||||
const isValid = computed(() => {
|
||||
return form.value.name.trim() && form.value.url.trim() && form.value.events.length > 0
|
||||
})
|
||||
|
||||
// Watch for webhook changes to populate form
|
||||
watch(() => props.webhook, (webhook) => {
|
||||
if (webhook) {
|
||||
form.value = {
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
secret: webhook.secret || '',
|
||||
events: [...webhook.events],
|
||||
instanceId: webhook.instanceId || null
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
events: [],
|
||||
instanceId: null
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const method = props.webhook ? 'PUT' : 'POST'
|
||||
const url = props.webhook
|
||||
? `/api/webhooks/${props.webhook.id}`
|
||||
: '/api/webhooks'
|
||||
|
||||
const result = await $fetch(url, {
|
||||
method,
|
||||
body: form.value
|
||||
})
|
||||
|
||||
emit('saved', result)
|
||||
} catch (error) {
|
||||
console.error('Error saving webhook:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user