All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
509 lines
23 KiB
TypeScript
509 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind } from 'lucide-react'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
|
import { getPendingBrainstormShares, respondToBrainstormShare } from '@/app/actions/brainstorm'
|
|
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
|
|
import { toast } from 'sonner'
|
|
import { useRefresh } from '@/lib/use-refresh'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface ShareRequest {
|
|
id: string
|
|
status: string
|
|
permission: string
|
|
createdAt: Date
|
|
note: {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
color: string
|
|
createdAt: Date
|
|
}
|
|
sharer: {
|
|
id: string
|
|
name: string | null
|
|
email: string
|
|
image: string | null
|
|
}
|
|
}
|
|
|
|
interface ReminderNote {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
reminder: Date | string | null
|
|
isReminderDone: boolean
|
|
}
|
|
|
|
// ── Memento brand tokens ──────────────────────────────────────────────────────
|
|
const C = {
|
|
blue: '#FDFDFE',
|
|
gold: '#D4A373',
|
|
green: '#A3B18A',
|
|
dark: '#1C1C1C',
|
|
beige: '#FDFDFE',
|
|
}
|
|
|
|
export function NotificationPanel() {
|
|
const { refreshNotes } = useRefresh()
|
|
const { t } = useLanguage()
|
|
const router = useRouter()
|
|
const [requests, setRequests] = useState<ShareRequest[]>([])
|
|
const [brainstormShares, setBrainstormShares] = useState<any[]>([])
|
|
const [reminders, setReminders] = useState<ReminderNote[]>([])
|
|
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const [shareData, brainstormData, reminderData, notifData] = await Promise.all([
|
|
getPendingShareRequests(),
|
|
getPendingBrainstormShares(),
|
|
getNotesWithReminders(),
|
|
getUnreadNotifications(),
|
|
])
|
|
setRequests(shareData as any)
|
|
setBrainstormShares(brainstormData as any || [])
|
|
setReminders((reminderData as any) || [])
|
|
setAppNotifications(notifData || [])
|
|
} catch (error: any) {
|
|
console.error('Failed to load notifications:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
const interval = setInterval(loadData, 30000)
|
|
const onFocus = () => loadData()
|
|
window.addEventListener('focus', onFocus)
|
|
return () => {
|
|
clearInterval(interval)
|
|
window.removeEventListener('focus', onFocus)
|
|
}
|
|
}, [loadData])
|
|
|
|
const now = new Date()
|
|
const activeReminders = reminders.filter(r => !r.isReminderDone && r.reminder)
|
|
const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
|
|
const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now)
|
|
|
|
const pendingCount = requests.length + brainstormShares.length + overdueReminders.length + appNotifications.length
|
|
|
|
const handleAccept = async (shareId: string) => {
|
|
try {
|
|
await respondToShareRequest(shareId, 'accept')
|
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
|
toast.success(t('notification.accepted'), {
|
|
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
|
duration: 3000,
|
|
})
|
|
refreshNotes(null)
|
|
setOpen(false)
|
|
} catch (error: any) {
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleDecline = async (shareId: string) => {
|
|
try {
|
|
await respondToShareRequest(shareId, 'decline')
|
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
|
toast.info(t('notification.declined'))
|
|
if (requests.length <= 1) setOpen(false)
|
|
} catch (error: any) {
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleToggleReminder = async (noteId: string, done: boolean) => {
|
|
try {
|
|
await toggleReminderDone(noteId, done)
|
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
|
|
refreshNotes(null)
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleMarkNotifRead = async (notifId: string) => {
|
|
await markNotificationRead(notifId)
|
|
setAppNotifications(prev => prev.filter(n => n.id !== notifId))
|
|
}
|
|
|
|
const handleMarkAllRead = async () => {
|
|
await markAllNotificationsRead()
|
|
setAppNotifications([])
|
|
}
|
|
|
|
const handleAcceptBrainstorm = async (shareId: string) => {
|
|
try {
|
|
await respondToBrainstormShare(shareId, 'accept')
|
|
setBrainstormShares(prev => prev.filter(s => s.id !== shareId))
|
|
toast.success(t('notification.accepted') || 'Accepted')
|
|
setOpen(false)
|
|
} catch (error: any) {
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleDeclineBrainstorm = async (shareId: string) => {
|
|
try {
|
|
await respondToBrainstormShare(shareId, 'decline')
|
|
setBrainstormShares(prev => prev.filter(s => s.id !== shareId))
|
|
toast.info(t('notification.declined') || 'Declined')
|
|
if (brainstormShares.length <= 1) setOpen(false)
|
|
} catch (error: any) {
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
|
|
|
// ── icon bg/color per notification type ──────────────────────────────────
|
|
const notifIconStyle = (type: string) => {
|
|
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
|
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
|
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
|
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
|
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
|
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
|
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
|
}
|
|
|
|
const notifLabelColor = (type: string) => {
|
|
if (type.startsWith('agent')) {
|
|
if (type === 'agent_failure') return '#EF4444'
|
|
if (type === 'brainstorm_invite') return '#A3B18A'
|
|
if (type === 'brainstorm_joined') return '#A3B18A'
|
|
return '#A47148'
|
|
}
|
|
return '#A3B18A'
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="relative p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
|
>
|
|
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
|
{pendingCount > 0 && (
|
|
<span
|
|
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
|
|
>
|
|
{pendingCount > 9 ? '9+' : pendingCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
|
|
{/* Header */}
|
|
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-4 w-4" style={{ color: C.dark }} />
|
|
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
|
|
{t('notification.notifications')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{appNotifications.length > 0 && (
|
|
<button
|
|
onClick={handleMarkAllRead}
|
|
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
|
|
title={t('notification.markAllRead') || 'Mark all read'}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
{pendingCount > 0 && (
|
|
<span
|
|
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold bg-brand-accent"
|
|
>
|
|
{pendingCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
|
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
|
|
</div>
|
|
) : !hasContent ? (
|
|
<div className="p-8 text-center">
|
|
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
|
|
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
|
</div>
|
|
) : (
|
|
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
|
|
|
|
{/* ── App notifications (agents, system) ── */}
|
|
{appNotifications.map((notif) => {
|
|
const isSlides = notif.type === 'agent_slides_ready'
|
|
const isCanvas = notif.type === 'agent_canvas_ready'
|
|
const canvasId = notif.relatedId
|
|
const iconStyle = notifIconStyle(notif.type)
|
|
|
|
return (
|
|
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
|
<div
|
|
className="flex items-start gap-3 cursor-pointer"
|
|
onClick={() => {
|
|
if (notif.actionUrl) {
|
|
handleMarkNotifRead(notif.id)
|
|
setOpen(false)
|
|
router.push(notif.actionUrl)
|
|
}
|
|
}}
|
|
>
|
|
{/* Icon badge */}
|
|
<div
|
|
className="mt-0.5 flex-none rounded-lg p-1.5"
|
|
style={{ background: iconStyle.bg, color: iconStyle.color }}
|
|
>
|
|
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
|
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
|
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
|
: notif.type === 'brainstorm_joined' ? <Wind className="w-3.5 h-3.5" />
|
|
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
|
|
: <AlertCircle className="w-3.5 h-3.5" />}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<span
|
|
className="text-[9px] font-bold uppercase tracking-[0.2em]"
|
|
style={{ color: notifLabelColor(notif.type) }}
|
|
>
|
|
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
|
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
|
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
|
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
|
|
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
|
|
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
|
|
{notif.type === 'system' && t('notification.systemNotification')}
|
|
</span>
|
|
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
|
{notif.message && (
|
|
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
|
|
)}
|
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
|
<Clock className="w-3 h-3" />
|
|
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
|
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Download PPTX button */}
|
|
{isSlides && canvasId && (
|
|
<div className="mt-2 ml-8">
|
|
<button
|
|
onClick={async () => {
|
|
handleMarkNotifRead(notif.id)
|
|
try {
|
|
const res = await fetch(`/api/canvas?id=${canvasId}`)
|
|
const data = await res.json()
|
|
if (!data.canvas?.data) throw new Error()
|
|
const parsed = JSON.parse(data.canvas.data)
|
|
if (!parsed.base64) throw new Error()
|
|
const bytes = Uint8Array.from(atob(parsed.base64), c => c.charCodeAt(0))
|
|
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
|
|
document.body.appendChild(a); a.click()
|
|
document.body.removeChild(a); URL.revokeObjectURL(url)
|
|
} catch { toast.error(t('notification.downloadFailed')) }
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
|
style={{ background: C.blue }}
|
|
>
|
|
<Download className="w-3 h-3" />
|
|
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* ── Overdue reminders ── */}
|
|
{overdueReminders.map((note) => (
|
|
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
onClick={() => handleToggleReminder(note.id, true)}
|
|
className="mt-0.5 flex-none transition-colors hover:opacity-70"
|
|
style={{ color: C.green }}
|
|
title={t('reminders.markDone')}
|
|
>
|
|
<Circle className="w-4 h-4" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
|
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
|
|
{t('reminders.overdue')}
|
|
</span>
|
|
</div>
|
|
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
|
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
|
<Clock className="w-3 h-3" />
|
|
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Upcoming reminders ── */}
|
|
{upcomingReminders.slice(0, 5).map((note) => (
|
|
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
|
<div className="text-[11px] text-foreground/40 mt-0.5">
|
|
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Brainstorm share invites ── */}
|
|
{brainstormShares.map((share) => (
|
|
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
|
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
|
|
>
|
|
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
|
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
|
|
Brainstorm
|
|
</span>
|
|
</div>
|
|
<p className="text-[13px] font-semibold truncate">
|
|
{share.sharer?.name || share.sharer?.email}
|
|
</p>
|
|
<p className="text-[11px] text-foreground/50 truncate">
|
|
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 ml-11">
|
|
<button
|
|
onClick={() => handleDeclineBrainstorm(share.id)}
|
|
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t('notification.decline') || 'Decline'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAcceptBrainstorm(share.id)}
|
|
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90 bg-brand-accent"
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
{t('notification.accept') || 'Accept'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Share requests ── */}
|
|
{requests.map((request) => (
|
|
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
|
<div className="flex items-start gap-3">
|
|
{/* Avatar */}
|
|
<div
|
|
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
|
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
|
|
>
|
|
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
|
|
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
|
|
Partage
|
|
</span>
|
|
</div>
|
|
<p className="text-[13px] font-semibold truncate">
|
|
{request.sharer.name || request.sharer.email}
|
|
</p>
|
|
<p className="text-[11px] text-foreground/50 truncate">
|
|
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 ml-11">
|
|
<button
|
|
onClick={() => handleDecline(request.id)}
|
|
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t('notification.decline') || 'Refuser'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAccept(request.id)}
|
|
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
|
style={{ background: C.blue }}
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
{t('notification.accept') || 'Accepter'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
{activeReminders.length > 0 && (
|
|
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
|
|
<a
|
|
href="/reminders"
|
|
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
|
|
style={{ color: C.blue }}
|
|
>
|
|
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|