feat: add in-app notifications system with bell icon, polling, and notifications page
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { i18n } from './i18n.js'
|
||||
import { auth } from './services/auth.js'
|
||||
import { favoritesService } from './services/favorites.js'
|
||||
import { notificationsService } from './services/notifications.js'
|
||||
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
||||
|
||||
async function initApp() {
|
||||
@@ -17,6 +18,7 @@ async function initApp() {
|
||||
// Restore auth session before loading components
|
||||
await auth.tryRestoreSession()
|
||||
favoritesService.init()
|
||||
notificationsService.init()
|
||||
|
||||
await import('./components/app-shell.js')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { i18n, t } from '../i18n.js'
|
||||
import { router } from '../router.js'
|
||||
import { auth } from '../services/auth.js'
|
||||
import { notificationsService } from '../services/notifications.js'
|
||||
|
||||
class AppHeader extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -24,6 +25,10 @@ class AppHeader extends HTMLElement {
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
})
|
||||
|
||||
this.notifUnsubscribe = notificationsService.subscribe(() => {
|
||||
this.updateNotificationBadge()
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -31,6 +36,7 @@ class AppHeader extends HTMLElement {
|
||||
document.removeEventListener('keydown', this.handleKeydown)
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
if (this.authUnsubscribe) this.authUnsubscribe()
|
||||
if (this.notifUnsubscribe) this.notifUnsubscribe()
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
@@ -173,6 +179,13 @@ class AppHeader extends HTMLElement {
|
||||
` : ''}
|
||||
|
||||
${auth.isLoggedIn() ? `
|
||||
<a href="#/notifications" class="btn btn-icon btn-outline notification-bell" title="${t('notifications.title')}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
</svg>
|
||||
${notificationsService.unreadCount > 0 ? `<span class="notification-badge">${notificationsService.unreadCount}</span>` : ''}
|
||||
</a>
|
||||
<div class="dropdown" id="profile-dropdown">
|
||||
<button
|
||||
class="btn btn-icon btn-outline"
|
||||
@@ -334,6 +347,25 @@ class AppHeader extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
updateNotificationBadge() {
|
||||
const bell = this.querySelector('.notification-bell')
|
||||
if (!bell) return
|
||||
|
||||
const existing = bell.querySelector('.notification-badge')
|
||||
if (notificationsService.unreadCount > 0) {
|
||||
if (existing) {
|
||||
existing.textContent = notificationsService.unreadCount
|
||||
} else {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'notification-badge'
|
||||
badge.textContent = notificationsService.unreadCount
|
||||
bell.appendChild(badge)
|
||||
}
|
||||
} else if (existing) {
|
||||
existing.remove()
|
||||
}
|
||||
}
|
||||
|
||||
updateTranslations() {
|
||||
this.render()
|
||||
this.setupEventListeners()
|
||||
|
||||
@@ -11,6 +11,7 @@ import './pages/page-favorites.js'
|
||||
import './pages/page-my-listings.js'
|
||||
import './pages/page-messages.js'
|
||||
import './pages/page-settings.js'
|
||||
import './pages/page-notifications.js'
|
||||
import './pages/page-not-found.js'
|
||||
|
||||
class AppShell extends HTMLElement {
|
||||
@@ -53,6 +54,7 @@ class AppShell extends HTMLElement {
|
||||
.register('/my-listings', 'page-my-listings')
|
||||
.register('/messages', 'page-messages')
|
||||
.register('/settings', 'page-settings')
|
||||
.register('/notifications', 'page-notifications')
|
||||
|
||||
router.handleRouteChange()
|
||||
}
|
||||
|
||||
317
js/components/pages/page-notifications.js
Normal file
317
js/components/pages/page-notifications.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { t, i18n } from '../../i18n.js'
|
||||
import { auth } from '../../services/auth.js'
|
||||
import { notificationsService } from '../../services/notifications.js'
|
||||
import { router } from '../../router.js'
|
||||
|
||||
class PageNotifications extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.loading = true
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render()
|
||||
this.loadNotifications()
|
||||
this.unsubscribe = i18n.subscribe(() => this.render())
|
||||
this.notifUnsubscribe = notificationsService.subscribe(() => {
|
||||
this.loading = false
|
||||
this.updateContent()
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe()
|
||||
if (this.notifUnsubscribe) this.notifUnsubscribe()
|
||||
}
|
||||
|
||||
async loadNotifications() {
|
||||
if (!auth.isLoggedIn()) {
|
||||
this.loading = false
|
||||
this.updateContent()
|
||||
return
|
||||
}
|
||||
await notificationsService.refresh()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */`
|
||||
<div class="notifications-page">
|
||||
<header class="page-header">
|
||||
<div class="page-header-row">
|
||||
<div>
|
||||
<h1>${t('notifications.title')}</h1>
|
||||
</div>
|
||||
${auth.isLoggedIn() && notificationsService.notifications.length > 0 ? `
|
||||
<button class="btn btn-outline btn-sm" id="mark-all-read">
|
||||
${t('notifications.markAllRead')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="notifications-content">
|
||||
${this.renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
updateContent() {
|
||||
const container = this.querySelector('#notifications-content')
|
||||
if (container) {
|
||||
container.innerHTML = this.renderContent()
|
||||
this.setupEventListeners()
|
||||
}
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (!auth.isLoggedIn()) {
|
||||
return /* html */`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔔</div>
|
||||
<h3>${t('auth.loginRequired')}</h3>
|
||||
<button class="btn btn-primary" id="login-btn">${t('auth.login')}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
return /* html */`
|
||||
<div class="notifications-list">
|
||||
${Array(3).fill(0).map(() => `
|
||||
<div class="notification-skeleton">
|
||||
<div class="skeleton-line skeleton-line-short"></div>
|
||||
<div class="skeleton-line"></div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const notifications = notificationsService.notifications
|
||||
if (notifications.length === 0) {
|
||||
return /* html */`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔔</div>
|
||||
<h3>${t('notifications.empty')}</h3>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
return /* html */`
|
||||
<div class="notifications-list">
|
||||
${notifications.map(n => this.renderNotification(n)).join('')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
renderNotification(n) {
|
||||
const typeText = t(`notifications.${n.type}`)
|
||||
const date = new Date(n.date_created)
|
||||
const timeAgo = this.formatTimeAgo(date)
|
||||
|
||||
return /* html */`
|
||||
<div class="notification-item ${n.read ? '' : 'unread'}" data-id="${n.id}" data-type="${n.type}" data-ref="${n.reference_id || ''}">
|
||||
<div class="notification-icon">${this.getIcon(n.type)}</div>
|
||||
<div class="notification-body">
|
||||
<p class="notification-text">${typeText}</p>
|
||||
<span class="notification-time">${timeAgo}</span>
|
||||
</div>
|
||||
<button class="btn btn-icon notification-delete" data-delete="${n.id}" title="${t('common.remove')}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
getIcon(type) {
|
||||
const icons = {
|
||||
listing_created: '📝',
|
||||
listing_published: '✅',
|
||||
listing_expired: '⏰',
|
||||
new_message: '💬',
|
||||
favorite_added: '❤️'
|
||||
}
|
||||
return icons[type] || '🔔'
|
||||
}
|
||||
|
||||
formatTimeAgo(date) {
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMin < 1) return t('messages.today')
|
||||
if (diffMin < 60) return `${diffMin} min`
|
||||
if (diffHours < 24) return `${diffHours}h`
|
||||
if (diffDays === 1) return t('messages.yesterday')
|
||||
return t('messages.daysAgo', { days: diffDays })
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.querySelector('#mark-all-read')?.addEventListener('click', async () => {
|
||||
await notificationsService.markAllRead()
|
||||
this.render()
|
||||
})
|
||||
|
||||
this.querySelector('#login-btn')?.addEventListener('click', () => {
|
||||
document.querySelector('auth-modal')?.show('login')
|
||||
})
|
||||
|
||||
this.querySelectorAll('.notification-item').forEach(item => {
|
||||
item.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('.notification-delete')) return
|
||||
|
||||
const id = item.dataset.id
|
||||
const type = item.dataset.type
|
||||
const ref = item.dataset.ref
|
||||
|
||||
await notificationsService.markRead(id)
|
||||
|
||||
if (ref) {
|
||||
if (type === 'new_message') {
|
||||
router.navigate(`/messages`)
|
||||
} else {
|
||||
router.navigate(`/listing/${ref}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.querySelectorAll('.notification-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation()
|
||||
await notificationsService.remove(btn.dataset.delete)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-notifications', PageNotifications)
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = /* css */`
|
||||
page-notifications .notifications-page {
|
||||
padding: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
page-notifications .page-header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-notifications .page-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
page-notifications .page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
page-notifications .notifications-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background-color: var(--color-border);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
page-notifications .notification-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background-color: var(--color-bg);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
page-notifications .notification-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
page-notifications .notification-item.unread {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
page-notifications .notification-item.unread .notification-text {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
page-notifications .notification-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
page-notifications .notification-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
page-notifications .notification-text {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
page-notifications .notification-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
page-notifications .notification-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
page-notifications .notification-item:hover .notification-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
page-notifications .empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-3xl);
|
||||
}
|
||||
|
||||
page-notifications .empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--space-md);
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
page-notifications .empty-state h3 {
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
|
||||
page-notifications .notification-skeleton {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
page-notifications .skeleton-line {
|
||||
height: 14px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
page-notifications .skeleton-line-short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
page-notifications .notification-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
@@ -761,6 +761,44 @@ class DirectusService {
|
||||
return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
|
||||
async getNotifications(userHash, options = {}) {
|
||||
const params = {
|
||||
fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'],
|
||||
filter: { user_hash: { _eq: userHash } },
|
||||
sort: ['-date_created'],
|
||||
limit: options.limit || 50
|
||||
}
|
||||
if (options.unreadOnly) {
|
||||
params.filter.read = { _eq: false }
|
||||
}
|
||||
const response = await this.get('/items/notifications', params)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getUnreadCount(userHash) {
|
||||
const response = await this.get('/items/notifications', {
|
||||
filter: { user_hash: { _eq: userHash }, read: { _eq: false } },
|
||||
aggregate: { count: 'id' }
|
||||
})
|
||||
return parseInt(response.data?.[0]?.count?.id || '0', 10)
|
||||
}
|
||||
|
||||
async markNotificationRead(id) {
|
||||
return this.patch(`/items/notifications/${id}`, { read: true })
|
||||
}
|
||||
|
||||
async markAllNotificationsRead(userHash) {
|
||||
const unread = await this.getNotifications(userHash, { unreadOnly: true })
|
||||
const updates = unread.map(n => this.markNotificationRead(n.id))
|
||||
return Promise.all(updates)
|
||||
}
|
||||
|
||||
async deleteNotification(id) {
|
||||
return this.delete(`/items/notifications/${id}`)
|
||||
}
|
||||
|
||||
getThumbnailUrl(fileId, size = 300) {
|
||||
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' })
|
||||
}
|
||||
|
||||
115
js/services/notifications.js
Normal file
115
js/services/notifications.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Notifications Service - Polls and manages user notifications
|
||||
*/
|
||||
|
||||
import { directus } from './directus.js'
|
||||
import { auth } from './auth.js'
|
||||
|
||||
class NotificationsService {
|
||||
constructor() {
|
||||
this.unreadCount = 0
|
||||
this.notifications = []
|
||||
this.listeners = new Set()
|
||||
this.pollTimer = null
|
||||
this.pollInterval = 60 * 1000 // 60 seconds
|
||||
this.userHash = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!auth.isLoggedIn()) return
|
||||
|
||||
this.userHash = await this._getUserHash()
|
||||
if (!this.userHash) return
|
||||
|
||||
await this.refresh()
|
||||
this.startPolling()
|
||||
}
|
||||
|
||||
async _getUserHash() {
|
||||
const user = await auth.getUser()
|
||||
return user?.id || null
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.userHash) return
|
||||
|
||||
try {
|
||||
const [count, notifications] = await Promise.all([
|
||||
directus.getUnreadCount(this.userHash),
|
||||
directus.getNotifications(this.userHash)
|
||||
])
|
||||
this.unreadCount = count
|
||||
this.notifications = notifications
|
||||
this.notify()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch notifications:', e)
|
||||
}
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
this.stopPolling()
|
||||
this.pollTimer = setInterval(() => this.refresh(), this.pollInterval)
|
||||
|
||||
document.addEventListener('visibilitychange', this._onVisibility)
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
document.removeEventListener('visibilitychange', this._onVisibility)
|
||||
}
|
||||
|
||||
_onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async markRead(id) {
|
||||
await directus.markNotificationRead(id)
|
||||
const n = this.notifications.find(n => n.id === id)
|
||||
if (n) {
|
||||
n.read = true
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
async markAllRead() {
|
||||
if (!this.userHash) return
|
||||
await directus.markAllNotificationsRead(this.userHash)
|
||||
this.notifications.forEach(n => n.read = true)
|
||||
this.unreadCount = 0
|
||||
this.notify()
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
await directus.deleteNotification(id)
|
||||
this.notifications = this.notifications.filter(n => n.id !== id)
|
||||
this.unreadCount = this.notifications.filter(n => !n.read).length
|
||||
this.notify()
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
this.listeners.add(callback)
|
||||
return () => this.listeners.delete(callback)
|
||||
}
|
||||
|
||||
notify() {
|
||||
for (const cb of this.listeners) {
|
||||
cb(this.unreadCount, this.notifications)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopPolling()
|
||||
this.listeners.clear()
|
||||
this.notifications = []
|
||||
this.unreadCount = 0
|
||||
this.userHash = null
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationsService = new NotificationsService()
|
||||
Reference in New Issue
Block a user