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:
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>
|
||||
Reference in New Issue
Block a user