203 lines
6.9 KiB
Vue
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>
|