feat: add in-app notifications system with bell icon, polling, and notifications page
This commit is contained in:
@@ -477,3 +477,24 @@ app-shell main {
|
|||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,4 +109,32 @@ if ($directusStatus >= 400) {
|
|||||||
exit;
|
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']);
|
echo json_encode(['ok' => true, 'listingId' => $listingId, 'action' => 'published']);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { i18n } from './i18n.js'
|
import { i18n } from './i18n.js'
|
||||||
import { auth } from './services/auth.js'
|
import { auth } from './services/auth.js'
|
||||||
import { favoritesService } from './services/favorites.js'
|
import { favoritesService } from './services/favorites.js'
|
||||||
|
import { notificationsService } from './services/notifications.js'
|
||||||
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
@@ -17,6 +18,7 @@ async function initApp() {
|
|||||||
// Restore auth session before loading components
|
// Restore auth session before loading components
|
||||||
await auth.tryRestoreSession()
|
await auth.tryRestoreSession()
|
||||||
favoritesService.init()
|
favoritesService.init()
|
||||||
|
notificationsService.init()
|
||||||
|
|
||||||
await import('./components/app-shell.js')
|
await import('./components/app-shell.js')
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { i18n, t } from '../i18n.js'
|
import { i18n, t } from '../i18n.js'
|
||||||
import { router } from '../router.js'
|
import { router } from '../router.js'
|
||||||
import { auth } from '../services/auth.js'
|
import { auth } from '../services/auth.js'
|
||||||
|
import { notificationsService } from '../services/notifications.js'
|
||||||
|
|
||||||
class AppHeader extends HTMLElement {
|
class AppHeader extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -24,6 +25,10 @@ class AppHeader extends HTMLElement {
|
|||||||
this.render()
|
this.render()
|
||||||
this.setupEventListeners()
|
this.setupEventListeners()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.notifUnsubscribe = notificationsService.subscribe(() => {
|
||||||
|
this.updateNotificationBadge()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -31,6 +36,7 @@ class AppHeader extends HTMLElement {
|
|||||||
document.removeEventListener('keydown', this.handleKeydown)
|
document.removeEventListener('keydown', this.handleKeydown)
|
||||||
window.removeEventListener('scroll', this.handleScroll)
|
window.removeEventListener('scroll', this.handleScroll)
|
||||||
if (this.authUnsubscribe) this.authUnsubscribe()
|
if (this.authUnsubscribe) this.authUnsubscribe()
|
||||||
|
if (this.notifUnsubscribe) this.notifUnsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll() {
|
handleScroll() {
|
||||||
@@ -173,6 +179,13 @@ class AppHeader extends HTMLElement {
|
|||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
${auth.isLoggedIn() ? `
|
${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">
|
<div class="dropdown" id="profile-dropdown">
|
||||||
<button
|
<button
|
||||||
class="btn btn-icon btn-outline"
|
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() {
|
updateTranslations() {
|
||||||
this.render()
|
this.render()
|
||||||
this.setupEventListeners()
|
this.setupEventListeners()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import './pages/page-favorites.js'
|
|||||||
import './pages/page-my-listings.js'
|
import './pages/page-my-listings.js'
|
||||||
import './pages/page-messages.js'
|
import './pages/page-messages.js'
|
||||||
import './pages/page-settings.js'
|
import './pages/page-settings.js'
|
||||||
|
import './pages/page-notifications.js'
|
||||||
import './pages/page-not-found.js'
|
import './pages/page-not-found.js'
|
||||||
|
|
||||||
class AppShell extends HTMLElement {
|
class AppShell extends HTMLElement {
|
||||||
@@ -53,6 +54,7 @@ class AppShell extends HTMLElement {
|
|||||||
.register('/my-listings', 'page-my-listings')
|
.register('/my-listings', 'page-my-listings')
|
||||||
.register('/messages', 'page-messages')
|
.register('/messages', 'page-messages')
|
||||||
.register('/settings', 'page-settings')
|
.register('/settings', 'page-settings')
|
||||||
|
.register('/notifications', 'page-notifications')
|
||||||
|
|
||||||
router.handleRouteChange()
|
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 : ''}`
|
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) {
|
getThumbnailUrl(fileId, size = 300) {
|
||||||
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' })
|
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()
|
||||||
@@ -265,6 +265,16 @@
|
|||||||
"currency": "Währung",
|
"currency": "Währung",
|
||||||
"currencyChanged": "Währung geändert"
|
"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": {
|
"payment": {
|
||||||
"title": "Zahlung",
|
"title": "Zahlung",
|
||||||
"listingFee": "Anzeigengebühr",
|
"listingFee": "Anzeigengebühr",
|
||||||
|
|||||||
@@ -265,6 +265,16 @@
|
|||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"currencyChanged": "Currency changed"
|
"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": {
|
"payment": {
|
||||||
"title": "Payment",
|
"title": "Payment",
|
||||||
"listingFee": "Listing Fee",
|
"listingFee": "Listing Fee",
|
||||||
|
|||||||
@@ -265,6 +265,16 @@
|
|||||||
"currency": "Devise",
|
"currency": "Devise",
|
||||||
"currencyChanged": "Devise modifiée"
|
"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": {
|
"payment": {
|
||||||
"title": "Paiement",
|
"title": "Paiement",
|
||||||
"listingFee": "Frais d'annonce",
|
"listingFee": "Frais d'annonce",
|
||||||
|
|||||||
Reference in New Issue
Block a user