diff --git a/css/components.css b/css/components.css index bba7377..e8f0338 100644 --- a/css/components.css +++ b/css/components.css @@ -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); +} diff --git a/docs/pow-server/btcpay-webhook.php b/docs/pow-server/btcpay-webhook.php index 339d019..a1b9f28 100644 --- a/docs/pow-server/btcpay-webhook.php +++ b/docs/pow-server/btcpay-webhook.php @@ -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']); diff --git a/js/app.js b/js/app.js index ff011d1..5634a2b 100644 --- a/js/app.js +++ b/js/app.js @@ -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') diff --git a/js/components/app-header.js b/js/components/app-header.js index ba430cd..1e9d591 100644 --- a/js/components/app-header.js +++ b/js/components/app-header.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() ? ` + + + + + + ${notificationsService.unreadCount > 0 ? `${notificationsService.unreadCount}` : ''} + + + +
+ ${this.renderContent()} +
+ + ` + this.setupEventListeners() + } + + updateContent() { + const container = this.querySelector('#notifications-content') + if (container) { + container.innerHTML = this.renderContent() + this.setupEventListeners() + } + } + + renderContent() { + if (!auth.isLoggedIn()) { + return /* html */` +
+
đź””
+

${t('auth.loginRequired')}

+ +
+ ` + } + + if (this.loading) { + return /* html */` +
+ ${Array(3).fill(0).map(() => ` +
+
+
+
+ `).join('')} +
+ ` + } + + const notifications = notificationsService.notifications + if (notifications.length === 0) { + return /* html */` +
+
đź””
+

${t('notifications.empty')}

+
+ ` + } + + return /* html */` +
+ ${notifications.map(n => this.renderNotification(n)).join('')} +
+ ` + } + + renderNotification(n) { + const typeText = t(`notifications.${n.type}`) + const date = new Date(n.date_created) + const timeAgo = this.formatTimeAgo(date) + + return /* html */` +
+
${this.getIcon(n.type)}
+
+

${typeText}

+ ${timeAgo} +
+ +
+ ` + } + + 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) diff --git a/js/services/directus.js b/js/services/directus.js index 28eb134..e1258b9 100644 --- a/js/services/directus.js +++ b/js/services/directus.js @@ -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' }) } diff --git a/js/services/notifications.js b/js/services/notifications.js new file mode 100644 index 0000000..56c4427 --- /dev/null +++ b/js/services/notifications.js @@ -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() diff --git a/locales/de.json b/locales/de.json index 5a9e70a..d437076 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 68137ae..21583b5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index ab5ef0f..7fa6843 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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",