Files
webapp/app/components/NovuNotificationBell.client.vue
2026-01-07 09:10:35 +07:00

203 lines
6.9 KiB
Vue

<template>
<div class="relative">
<!-- Bell Button -->
<button
@click="toggleDropdown"
class="btn btn-ghost btn-sm btn-square"
aria-label="Notifications"
>
<Icon name="lucide:bell" size="18" />
<!-- Unread Badge -->
<span
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 min-w-[1.25rem] h-5 px-1 rounded-full bg-error text-error-content text-xs font-semibold flex items-center justify-center"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<!-- Overlay -->
<Transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-base-content/30 z-40"
@click="isOpen = false"
/>
</Transition>
<!-- Slide-in Panel -->
<Transition
enter-active-class="transition ease-out duration-300"
enter-from-class="transform translate-x-full"
enter-to-class="transform translate-x-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="transform translate-x-0"
leave-to-class="transform translate-x-full"
>
<div
v-if="isOpen"
class="fixed right-0 top-0 h-full w-full sm:w-96 lg:w-1/3 bg-base-100 border border-base-300 shadow-2xl z-50 flex flex-col"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h3 class="text-lg font-semibold text-base-content">Notifications</h3>
<div class="flex items-center gap-3">
<button
v-if="unreadCount > 0"
@click="handleMarkAllAsRead"
class="text-sm text-primary hover:text-primary/80 font-medium"
>
Mark all as read
</button>
<button
@click="isOpen = false"
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Notifications List -->
<div class="flex-1 overflow-y-auto">
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-base-content/50" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Empty State -->
<div v-else-if="notifications.length === 0" class="flex flex-col items-center justify-center py-8 px-4">
<svg class="w-12 h-12 text-base-content/30 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-sm text-base-content/60">No notifications</p>
</div>
<!-- Notification Items -->
<div v-else>
<button
v-for="notification in notifications"
:key="notification.id"
@click="handleNotificationClick(notification)"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 last:border-b-0 transition-colors"
:class="{ 'bg-primary/10': !notification.read }"
>
<div class="flex items-start gap-3">
<!-- Unread Indicator -->
<div class="flex-shrink-0 mt-1.5">
<div
v-if="!notification.read"
class="w-2 h-2 rounded-full bg-primary"
></div>
<div v-else class="w-2 h-2"></div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="text-sm text-base-content line-clamp-2">
{{ notification.content || notification.payload?.body || 'New notification' }}
</p>
<p class="mt-1 text-xs text-base-content/60">
{{ formatTime(notification.createdAt) }}
</p>
</div>
</div>
</button>
</div>
</div>
<!-- Footer -->
<div v-if="notifications.length > 0" class="px-4 py-2 border-t border-base-300 bg-base-200">
<NuxtLink
to="/clientarea/notifications"
@click="isOpen = false"
class="text-xs text-primary hover:text-primary/80 font-medium"
>
All notifications
</NuxtLink>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
const props = defineProps<{
subscriberId: string
}>()
const { init, notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNovu()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
// Init on mount
onMounted(() => {
if (props.subscriberId) {
init(props.subscriberId)
}
})
// Re-init on subscriber change
watch(() => props.subscriberId, (newId) => {
if (newId) {
init(newId)
}
})
// Close on click outside
onClickOutside(dropdownRef, () => {
isOpen.value = false
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const handleMarkAllAsRead = async () => {
await markAllAsRead()
}
const handleNotificationClick = async (notification: { id: string; read: boolean; cta?: { data?: { url?: string } } }) => {
if (!notification.read) {
await markAsRead(notification.id)
}
// Navigate if CTA URL present
if (notification.cta?.data?.url) {
isOpen.value = false
navigateTo(notification.cta.data.url)
}
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min ago`
if (diffHours < 24) return `${diffHours} h ago`
if (diffDays < 7) return `${diffDays} d ago`
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
</script>