feat(frontend) : add NotificationBell component with dropdown
This commit is contained in:
171
frontend/components/notification/NotificationBell.vue
Normal file
171
frontend/components/notification/NotificationBell.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div ref="bellRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Icon name="mdi:bell-outline" size="24" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">
|
||||
{{ $t('notification.title') }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
{{ $t('notification.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
{{ $t('notification.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
|
||||
:class="{ 'bg-primary-50': !notif.isRead }"
|
||||
@click="handleClick(notif)"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-neutral-800 truncate">
|
||||
{{ notif.title }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
||||
{{ notif.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ formatRelativeDate(notif.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from '~/services/dto/notification'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
|
||||
const {
|
||||
unreadCount,
|
||||
notifications,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useNotifications()
|
||||
|
||||
const bellRef = ref<HTMLElement>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllAsRead()
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMin < 1) return t('notification.timeAgo.now')
|
||||
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
|
||||
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
|
||||
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
|
||||
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (!bellRef.value?.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startPolling()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user