feat: add in-app notifications system with bell icon, polling, and notifications page

This commit is contained in:
2026-02-07 14:18:00 +01:00
parent 1bd44e6632
commit f6ba0085f9
11 changed files with 585 additions and 0 deletions

View File

@@ -477,3 +477,24 @@ app-shell main {
right: 0;
left: auto;
}
/* Notification Badge */
.notification-bell {
position: relative;
}
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: var(--font-weight-bold);
line-height: 18px;
text-align: center;
color: #fff;
background-color: var(--color-accent);
border-radius: var(--radius-full);
}

View File

@@ -109,4 +109,32 @@ if ($directusStatus >= 400) {
exit;
}
// Create notification for listing owner
$listingData = json_decode($directusResponse, true);
$userCreated = $listingData['data']['user_created'] ?? null;
if ($userCreated) {
$notifPayload = json_encode([
'user_hash' => $userCreated,
'type' => 'listing_published',
'reference_id' => $listingId,
'read' => false,
]);
$notifUrl = DIRECTUS_URL . '/items/notifications';
$nch = curl_init($notifUrl);
curl_setopt_array($nch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $notifPayload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . DIRECTUS_TOKEN,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($nch);
curl_close($nch);
}
echo json_encode(['ok' => true, 'listingId' => $listingId, 'action' => 'published']);

View File

@@ -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')

View File

@@ -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()

View File

@@ -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()
}

View 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)

View File

@@ -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' })
}

View 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()

View File

@@ -265,6 +265,16 @@
"currency": "Währung",
"currencyChanged": "Währung geändert"
},
"notifications": {
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"listing_created": "Deine Anzeige wurde erstellt",
"listing_published": "Deine Anzeige wurde veröffentlicht",
"listing_expired": "Deine Anzeige ist abgelaufen",
"new_message": "Du hast eine neue Nachricht",
"favorite_added": "Jemand hat deine Anzeige gemerkt"
},
"payment": {
"title": "Zahlung",
"listingFee": "Anzeigengebühr",

View File

@@ -265,6 +265,16 @@
"currency": "Currency",
"currencyChanged": "Currency changed"
},
"notifications": {
"title": "Notifications",
"empty": "No notifications",
"markAllRead": "Mark all as read",
"listing_created": "Your listing has been created",
"listing_published": "Your listing has been published",
"listing_expired": "Your listing has expired",
"new_message": "You have a new message",
"favorite_added": "Someone saved your listing"
},
"payment": {
"title": "Payment",
"listingFee": "Listing Fee",

View File

@@ -265,6 +265,16 @@
"currency": "Devise",
"currencyChanged": "Devise modifiée"
},
"notifications": {
"title": "Notifications",
"empty": "Aucune notification",
"markAllRead": "Tout marquer comme lu",
"listing_created": "Votre annonce a été créée",
"listing_published": "Votre annonce a été publiée",
"listing_expired": "Votre annonce a expiré",
"new_message": "Vous avez un nouveau message",
"favorite_added": "Quelqu'un a sauvegardé votre annonce"
},
"payment": {
"title": "Paiement",
"listingFee": "Frais d'annonce",