/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
notifications
/
/opt/canhelp/apps/web/src/app/notifications
mkdir
upload
Name
Size
Mode
Actions
page.tsx
12115
0644
edit
dl
rm
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 ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <rect x="2" y="7" width="20" height="14" rx="2" /> <path d="M16 21V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v16" /> </svg> ) case 'offer_accepted': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <circle cx="12" cy="12" r="10" /> <polyline points="9 12 11 14 15 10" /> </svg> ) case 'offer_declined': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <circle cx="12" cy="12" r="10" /> <line x1="15" y1="9" x2="9" y2="15" /> <line x1="9" y1="9" x2="15" y2="15" /> </svg> ) case 'task_completed': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <polyline points="20 6 9 17 4 12" /> </svg> ) case 'task_cancelled': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> <line x1="12" y1="9" x2="12" y2="13" /> <line x1="12" y1="17" x2="12.01" y2="17" /> </svg> ) case 'new_message': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" /> </svg> ) case 'new_review': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> </svg> ) case 'task_invite': return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2" /> <rect x="8" y="2" width="8" height="4" rx="1" ry="1" /> </svg> ) default: return ( <svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9" /> <path d="M13.73 21a2 2 0 01-3.46 0" /> </svg> ) } } const typeColor: Record<string, string> = { 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<Notification[]>([]) 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 <div className="text-center py-20 text-white">{t('common.loading')}</div> 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 ( <div className="max-w-2xl mx-auto px-4 py-8"> <div className="flex justify-between items-center mb-6"> <h1 className="text-2xl font-bold text-white"> {t('notif.title')} {unreadCount > 0 && <span className="text-white/70 font-normal text-xl">({unreadCount})</span>} </h1> {unreadCount > 0 && ( <button onClick={handleMarkAll} className="text-sm text-white/80 hover:text-white hover:underline transition" > {t('notif.mark_all')} </button> )} </div> {/* Notification settings */} {notifications.length === 0 ? ( <div className="text-center py-20 text-white/60">{t('notif.empty')}</div> ) : ( <div className="space-y-2"> {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 ( <div key={n.id} onClick={handleClick} className={`flex items-start gap-3 p-4 rounded-xl border transition ${ link ? 'cursor-pointer' : 'cursor-default' } ${ n.isRead ? 'bg-white/90 border-white/20 hover:bg-white' : 'bg-white border-green-300 shadow-sm hover:bg-green-50' }`} > <div className={`p-2.5 rounded-xl flex-shrink-0 ${typeColor[n.type] ?? 'bg-gray-100 text-gray-500'}`}> <NotifIcon type={n.type} /> </div> <div className="flex-1 min-w-0"> <p className={`font-medium ${n.isRead ? 'text-gray-600' : 'text-gray-900'}`}> {t(`notif.type.${n.type}`, t('notif.type.generic', 'Notification'))} </p> {n.body && ( <p className="text-sm text-gray-500 mt-0.5 truncate"> {(() => { 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 })()} </p> )} <p className="text-xs text-gray-400 mt-1"> {relativeTime(n.createdAt)} </p> </div> <div className="flex flex-col items-end gap-2 shrink-0" onClick={(e) => e.stopPropagation()}> <button onClick={(e) => handleDismiss(e, n.id)} className="p-1 rounded-md text-gray-300 hover:text-red-400 hover:bg-red-50 transition" title={t('common.delete', 'Delete')} > <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> </button> <div className="flex items-center gap-1.5"> {!n.isRead && <span className="w-2 h-2 bg-green-500 rounded-full" />} </div> </div> </div> ) })} </div> )} </div> ) }
Save
cmd:
run