/opt/canhelp/apps/web/src/app/notifications
Edit: /opt/canhelp/apps/web/src/app/notifications/page.tsx (12115B)
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth'
import { useLocale } from '@/context/locale'
import { getNotifications, markAllRead, markRead, dismissNotification } from '@/lib/api'
import type { Notification } from '@canhelp/shared'
function NotifIcon({ type }: { type: string }) {
const cls = 'w-5 h-5'
switch (type) {
case 'new_offer':
return (
)
case 'offer_accepted':
return (
)
case 'offer_declined':
return (
)
case 'task_completed':
return (
)
case 'task_cancelled':
return (
)
case 'new_message':
return (
)
case 'new_review':
return (
)
case 'task_invite':
return (
)
default:
return (
)
}
}
const typeColor: Record
= {
new_offer: 'bg-green-100 text-green-600',
offer_accepted: 'bg-green-100 text-green-600',
offer_declined: 'bg-red-100 text-red-600',
task_completed: 'bg-emerald-100 text-emerald-600',
task_cancelled: 'bg-amber-100 text-amber-600',
new_message: 'bg-purple-100 text-purple-600',
new_review: 'bg-yellow-100 text-yellow-600',
task_invite: 'bg-indigo-100 text-indigo-600',
referral_reward: 'bg-cyan-100 text-cyan-700',
specialist_card_submitted: 'bg-amber-100 text-amber-700',
specialist_card_approved: 'bg-green-100 text-green-700',
specialist_card_rejected: 'bg-rose-100 text-rose-700',
}
/** Extract the quoted task title from a stored body string (any locale) */
function extractParam(body?: string | null): string | null {
if (!body) return null
const m = body.match(/[«""](.+?)[»""]/)
return m?.[1] ?? null
}
/** Extract rating number from a stored review body */
function extractRating(body?: string | null): string | null {
if (!body) return null
const m = body.match(/(\d+)\s*[★\*]/)
return m?.[1] ?? null
}
/** Extract first integer from text (for referral days, etc.) */
function extractNumber(body?: string | null): string | null {
if (!body) return null
const m = body.match(/(\d+)/)
return m?.[1] ?? null
}
export default function NotificationsPage() {
const { data: session, isPending } = useSession()
const router = useRouter()
const { t, locale } = useLocale()
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isPending && !session) router.push('/login')
}, [session, isPending])
useEffect(() => {
if (!session) return
getNotifications()
.then(setNotifications)
.catch(() => {})
.finally(() => setLoading(false))
}, [session])
async function handleMarkAll() {
await markAllRead()
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
}
async function handleMark(id: string) {
await markRead(id)
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, isRead: true } : n))
}
async function handleDismiss(e: React.MouseEvent, id: string) {
e.stopPropagation()
await dismissNotification(id).catch(() => {})
setNotifications((prev) => prev.filter((n) => n.id !== id))
}
if (isPending || loading) return {t('common.loading')}
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const sec = Math.floor(diff / 1000)
const min = Math.floor(sec / 60)
const hrs = Math.floor(min / 60)
const days = Math.floor(hrs / 24)
if (locale === 'ru') {
if (sec < 60) return 'только что'
if (min < 60) return `${min} мин. назад`
if (hrs < 24) return `${hrs} ч. назад`
if (days === 1) return 'вчера'
if (days < 7) return `${days} дн. назад`
} else if (locale === 'uk') {
if (sec < 60) return 'щойно'
if (min < 60) return `${min} хв. тому`
if (hrs < 24) return `${hrs} год. тому`
if (days === 1) return 'вчора'
if (days < 7) return `${days} дн. тому`
} else if (locale === 'en') {
if (sec < 60) return 'just now'
if (min < 60) return `${min}m ago`
if (hrs < 24) return `${hrs}h ago`
if (days === 1) return 'yesterday'
if (days < 7) return `${days}d ago`
} else {
if (sec < 60) return 'μόλις τώρα'
if (min < 60) return `${min} λ. πριν`
if (hrs < 24) return `${hrs} ω. πριν`
if (days === 1) return 'χθες'
if (days < 7) return `${days} μ. πριν`
}
return new Date(dateStr).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short', year: days > 365 ? 'numeric' : undefined })
}
const unreadCount = notifications.filter((n) => !n.isRead).length
const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'uk' ? 'uk-UA' : locale === 'en' ? 'en-US' : 'el-GR'
return (
{t('notif.title')} {unreadCount > 0 && ({unreadCount})}
{unreadCount > 0 && (
)}
{/* Notification settings */}
{notifications.length === 0 ? (
{t('notif.empty')}
) : (
{notifications.map((n) => {
const link = n.type === 'referral_reward'
? '/profile?tab=referral'
: n.referenceId
? n.type === 'new_message' ? `/chat?room=${n.referenceId}` : `/tasks/${n.referenceId}`
: null
const handleClick = async () => {
if (!n.isRead) await handleMark(n.id)
if (link) router.push(link)
}
return (
{t(`notif.type.${n.type}`, t('notif.type.generic', 'Notification'))}
{n.body && (
{(() => {
if (n.type === 'new_message') return n.body
if (n.type === 'new_review') {
const rating = extractRating(n.body)
const tpl = t(`notif.body.new_review`, '')
return rating && tpl ? tpl.replace('{rating}', rating) : n.body
}
if (n.type === 'referral_reward') {
const days = extractNumber(n.body)
const plan = extractParam(n.body)
const tpl = t('notif.body.referral_reward', '')
if (tpl && days && plan) return tpl.replace('{days}', days).replace('{plan}', plan)
return tpl || n.body
}
const title = extractParam(n.body)
const tpl = t(`notif.body.${n.type}`, '')
if (tpl && title) return tpl.replace('{title}', title)
return tpl || n.body
})()}
)}
{relativeTime(n.createdAt)}
e.stopPropagation()}>
{!n.isRead && }
)
})}
)}
)
}