notification implemented
This commit is contained in:
@@ -9,8 +9,13 @@ type NotificationItem = {
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
sentAt: string | null
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
providerMessageId: string | null
|
||||
locale: string
|
||||
}
|
||||
|
||||
type PreferenceItem = {
|
||||
@@ -30,19 +35,42 @@ const COMPANY_EVENTS = [
|
||||
|
||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||
}
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [history, setHistory] = useState<NotificationItem[]>([])
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
|
||||
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
|
||||
inbox: 'Inbox',
|
||||
history: 'History',
|
||||
preferences: 'Preferences',
|
||||
markAllRead: 'Mark all as read',
|
||||
loading: 'Loading notifications…',
|
||||
@@ -50,24 +78,35 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'Read',
|
||||
markRead: 'Mark as read',
|
||||
event: 'Event',
|
||||
channel: 'Channel',
|
||||
status: 'Status',
|
||||
sentAt: 'Sent at',
|
||||
date: 'Date',
|
||||
allChannels: 'All channels',
|
||||
allStatuses: 'All statuses',
|
||||
savePreferences: 'Save preferences',
|
||||
saving: 'Saving…',
|
||||
failedLoad: 'Failed to load notifications',
|
||||
failedSave: 'Failed to save preferences',
|
||||
noHistory: 'No notification history found.',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
||||
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'New reservation',
|
||||
RESERVATION_CANCELLED: 'Reservation cancelled',
|
||||
PAYMENT_RECEIVED: 'Payment received',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
||||
MAINTENANCE_DUE: 'Maintenance à prévoir',
|
||||
MAINTENANCE_DUE: 'Maintenance due',
|
||||
OFFER_EXPIRING: 'Offer expiring',
|
||||
BOOKING_CONFIRMED: 'Booking confirmed',
|
||||
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Suivez les alertes de l’application et gérez les préférences de diffusion de votre équipe.',
|
||||
subtitle: 'Suivez les alertes, auditez l'historique de diffusion et gérez les préférences de votre équipe.',
|
||||
inbox: 'Boîte de réception',
|
||||
history: 'Historique',
|
||||
preferences: 'Préférences',
|
||||
markAllRead: 'Tout marquer comme lu',
|
||||
loading: 'Chargement des notifications…',
|
||||
@@ -75,24 +114,35 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'Lu',
|
||||
markRead: 'Marquer comme lu',
|
||||
event: 'Événement',
|
||||
channel: 'Canal',
|
||||
status: 'Statut',
|
||||
sentAt: 'Envoyé le',
|
||||
date: 'Date',
|
||||
allChannels: 'Tous les canaux',
|
||||
allStatuses: 'Tous les statuts',
|
||||
savePreferences: 'Enregistrer les préférences',
|
||||
saving: 'Enregistrement…',
|
||||
failedLoad: 'Échec du chargement des notifications',
|
||||
failedSave: 'Échec de l’enregistrement des préférences',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l’application', PUSH: 'Push' } as Record<string, string>,
|
||||
failedSave: 'Échec de l'enregistrement des préférences',
|
||||
noHistory: 'Aucun historique de notification trouvé.',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l'application', PUSH: 'Push' } as Record<string, string>,
|
||||
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'Nouvelle réservation',
|
||||
RESERVATION_CANCELLED: 'Réservation annulée',
|
||||
PAYMENT_RECEIVED: 'Paiement reçu',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'Fin d’essai proche',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'Fin d'essai proche',
|
||||
MAINTENANCE_DUE: 'Maintenance due',
|
||||
OFFER_EXPIRING: 'Offre expirante',
|
||||
BOOKING_CONFIRMED: 'Réservation confirmée',
|
||||
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الإشعارات',
|
||||
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
|
||||
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
|
||||
inbox: 'صندوق الوارد',
|
||||
history: 'السجل',
|
||||
preferences: 'التفضيلات',
|
||||
markAllRead: 'تحديد الكل كمقروء',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
@@ -100,11 +150,19 @@ export default function DashboardNotificationsPage() {
|
||||
read: 'مقروء',
|
||||
markRead: 'تحديد كمقروء',
|
||||
event: 'الحدث',
|
||||
channel: 'القناة',
|
||||
status: 'الحالة',
|
||||
sentAt: 'وقت الإرسال',
|
||||
date: 'التاريخ',
|
||||
allChannels: 'جميع القنوات',
|
||||
allStatuses: 'جميع الحالات',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
failedLoad: 'فشل تحميل الإشعارات',
|
||||
failedSave: 'فشل حفظ التفضيلات',
|
||||
noHistory: 'لا يوجد سجل إشعارات.',
|
||||
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
||||
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'حجز جديد',
|
||||
RESERVATION_CANCELLED: 'إلغاء حجز',
|
||||
@@ -112,6 +170,8 @@ export default function DashboardNotificationsPage() {
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
||||
MAINTENANCE_DUE: 'صيانة مستحقة',
|
||||
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
||||
BOOKING_CONFIRMED: 'تأكيد الحجز',
|
||||
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
@@ -136,10 +196,34 @@ export default function DashboardNotificationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
setHistoryLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
const qs = params.toString()
|
||||
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
|
||||
setHistory(data)
|
||||
setHistoryLoaded(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? copy.failedLoad)
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') {
|
||||
loadHistory()
|
||||
}
|
||||
}, [activeTab, filterChannel, filterStatus])
|
||||
|
||||
async function markRead(id: string) {
|
||||
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
|
||||
setNotifications((current) =>
|
||||
@@ -184,12 +268,16 @@ export default function DashboardNotificationsPage() {
|
||||
}))
|
||||
}
|
||||
|
||||
function labelEvent(type: string) {
|
||||
return copy.events[type] ?? type.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -199,6 +287,13 @@ export default function DashboardNotificationsPage() {
|
||||
>
|
||||
{copy.inbox}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
{copy.history}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preferences')}
|
||||
@@ -209,8 +304,9 @@ export default function DashboardNotificationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="card p-4 text-sm text-red-600">{error}</div> : null}
|
||||
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
|
||||
{/* ── Inbox ── */}
|
||||
{activeTab === 'inbox' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
@@ -225,34 +321,118 @@ export default function DashboardNotificationsPage() {
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{labelEvent(notification.type)}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
||||
{copy.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{notification.body}</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
|
||||
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
|
||||
</article>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{/* ── History ── */}
|
||||
{activeTab === 'history' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||
>
|
||||
<option value="">{copy.allChannels}</option>
|
||||
{COMPANY_CHANNELS.map((ch) => (
|
||||
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200"
|
||||
>
|
||||
<option value="">{copy.allStatuses}</option>
|
||||
{Object.keys(copy.statuses).map((s) => (
|
||||
<option key={s} value={s}>{copy.statuses[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
|
||||
) : historyLoaded && history.length === 0 ? (
|
||||
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||
<tr>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
|
||||
{history.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-[#0d1b38]/60">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
|
||||
{labelEvent(item.type)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{copy.channels[item.channel] ?? item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[260px] px-4 py-3">
|
||||
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{copy.statuses[item.status] ?? item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Preferences ── */}
|
||||
{activeTab === 'preferences' ? (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<thead className="bg-slate-50 dark:bg-[#0d1b38]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
|
||||
{copy.channels[channel] ?? channel.replace('_', ' ')}
|
||||
</th>
|
||||
))}
|
||||
@@ -260,8 +440,8 @@ export default function DashboardNotificationsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPANY_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
|
||||
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
@@ -277,13 +457,13 @@ export default function DashboardNotificationsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-100 p-4">
|
||||
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
|
||||
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.saving : copy.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
@@ -209,7 +209,6 @@ function LocalSignInForm({
|
||||
unexpectedError: string
|
||||
}
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { setLanguage } = useDashboardI18n()
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -221,7 +220,6 @@ function LocalSignInForm({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const adminNext = searchParams.get('next') || '/dashboard'
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const employeeAppRedirect = toDashboardAppPath(employeeRedirect)
|
||||
|
||||
function redirectAdmin(token: string) {
|
||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
||||
@@ -243,6 +241,7 @@ function LocalSignInForm({
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
@@ -254,8 +253,10 @@ function LocalSignInForm({
|
||||
}
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) })
|
||||
router.push(employeeAppRedirect)
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Bell } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
@@ -34,6 +35,11 @@ export default function TopBar() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
function getEmployeeToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
}
|
||||
|
||||
const title = (() => {
|
||||
if (!mounted) return dict.titles['/']
|
||||
if (dict.titles[appPath]) return dict.titles[appPath]
|
||||
@@ -42,6 +48,11 @@ export default function TopBar() {
|
||||
return dict.titles['/']
|
||||
})()
|
||||
async function refreshUnreadCount() {
|
||||
if (!getEmployeeToken()) {
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
@@ -60,8 +71,29 @@ export default function TopBar() {
|
||||
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
|
||||
const socketUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1').replace('/api/v1', '')
|
||||
const socket = io(socketUrl, { auth: { token }, transports: ['websocket'] })
|
||||
|
||||
socket.on('notification', () => {
|
||||
setUnreadCount((prev) => prev + 1)
|
||||
window.dispatchEvent(new Event('notifications:updated'))
|
||||
})
|
||||
|
||||
return () => { socket.disconnect() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
if (!getEmployeeToken()) {
|
||||
setNotifications([])
|
||||
setLoadingNotifs(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoadingNotifs(true)
|
||||
apiFetch<Array<{
|
||||
|
||||
Reference in New Issue
Block a user