Files
perauset-e2e-mrfzphpz/apps/app/src/components/notification-bell.tsx
2026-07-11 08:35:02 +02:00

283 lines
7.2 KiB
TypeScript

import { useCallback } from 'react'
import {
ActionIcon,
Indicator,
Popover,
Drawer,
Stack,
Text,
Group,
Box,
UnstyledButton,
Anchor,
Divider,
} from '@pikku/mantine/core'
import { useDisclosure, useMediaQuery } from '@mantine/hooks'
import { asI18n } from '@pikku/react'
import { m } from '@/i18n/messages'
import { Bell, BellOff } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
interface NotificationItem {
notificationId: string
type: string
title: string
body: string | null
entityType: string | null
entityId: string | null
readAt: string | Date | null
createdAt: string | Date
}
function entityLink(
entityType: string | null,
entityId: string | null,
): string | null {
if (!entityType || !entityId) return null
switch (entityType) {
case 'task':
return '/tasks'
case 'stay':
return '/stays'
case 'boat_trip':
return '/boats'
case 'retreat':
return '/retreats'
default:
return null
}
}
function formatTimestamp(val: string | Date): string {
const d = typeof val === 'string' ? new Date(val) : val
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDays = Math.floor(diffHr / 24)
if (diffDays < 7) return `${diffDays}d ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function NotificationEntry({
n,
onRead,
}: {
n: NotificationItem
onRead: (id: string, link: string | null) => void
}) {
const isRead = !!n.readAt
const link = entityLink(n.entityType, n.entityId)
return (
<UnstyledButton
onClick={() => onRead(n.notificationId, link)}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
borderRadius: 8,
backgroundColor: isRead ? 'transparent' : 'rgba(42, 123, 136, 0.04)',
transition: 'background-color 0.15s ease',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: isRead ? 'transparent' : '#2A7B88',
flexShrink: 0,
marginTop: 6,
}}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="sm" fw={isRead ? 400 : 600} lineClamp={1}>
{asI18n(n.title)}
</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{asI18n(formatTimestamp(n.createdAt))}
</Text>
</Group>
{n.body && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{asI18n(n.body)}
</Text>
)}
</Box>
</Group>
</UnstyledButton>
)
}
export function NotificationBell() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const isMobile = useMediaQuery('(max-width: 768px)')
const [popoverOpened, { toggle: togglePopover, close: closePopover }] =
useDisclosure()
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure()
const { data } = usePikkuQuery(
'listNotifications',
{ limit: 5, offset: 0 },
{ refetchInterval: 30_000 },
)
const items = (data?.items ?? []) as NotificationItem[]
const unreadCount = items.filter((n) => !n.readAt).length
const markReadMutation = usePikkuMutation('markNotificationRead', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listNotifications'] })
},
})
const handleRead = useCallback(
(notificationId: string, link: string | null) => {
// Optimistic update
queryClient.setQueryData<{ items?: NotificationItem[] }>(
['listNotifications', { limit: 5, offset: 0 }],
(old) => {
if (!old?.items) return old
return {
...old,
items: old.items.map((n) =>
n.notificationId === notificationId
? { ...n, readAt: new Date().toISOString() }
: n,
),
}
},
)
markReadMutation.mutate({ notificationId })
if (link) {
closePopover()
closeDrawer()
navigate({ to: link })
}
},
[queryClient, markReadMutation, closePopover, closeDrawer, navigate],
)
const handleToggle = () => {
if (isMobile) {
toggleDrawer()
} else {
togglePopover()
}
}
const bellButton = (
<Indicator
size={16}
label={unreadCount > 0 ? asI18n(String(unreadCount)) : undefined}
disabled={unreadCount === 0}
color="red"
offset={4}
processing={unreadCount > 0}
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={handleToggle}
aria-label={asI18n('Notifications')}
>
<Bell size={20} />
</ActionIcon>
</Indicator>
)
const content = (
<Stack gap={0}>
<Group justify="space-between" px="xs" py="xs">
<Text fw={600} size="sm" style={{ fontFamily: '"Cormorant", serif' }}>
{m.notifications_title()}
</Text>
{unreadCount > 0 && (
<Text size="xs" c="teal" fw={500}>
{m.notifications_unread_count({ count: unreadCount })}
</Text>
)}
</Group>
<Divider />
{items.length === 0 ? (
<Stack align="center" gap="xs" py="lg">
<BellOff size={28} color="#aaa" />
<Text size="sm" c="dimmed">
{m.notifications_empty()}
</Text>
</Stack>
) : (
<Stack gap={2} p={4}>
{items.map((n) => (
<NotificationEntry key={n.notificationId} n={n} onRead={handleRead} />
))}
</Stack>
)}
<Divider />
<Box py="xs" px="xs" style={{ textAlign: 'center' }}>
<Anchor
component="button"
type="button"
size="sm"
onClick={() => {
closePopover()
closeDrawer()
navigate({ to: '/notifications' })
}}
>
{m.notifications_view_all()}
</Anchor>
</Box>
</Stack>
)
if (isMobile) {
return (
<>
{bellButton}
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
position="bottom"
size="60%"
title={undefined}
styles={{
content: { borderTopLeftRadius: 16, borderTopRightRadius: 16 },
}}
>
{content}
</Drawer>
</>
)
}
return (
<Popover
opened={popoverOpened}
onChange={(o) => {
if (!o) closePopover()
}}
width={360}
position="bottom-end"
shadow="lg"
radius="md"
>
<Popover.Target>{bellButton}</Popover.Target>
<Popover.Dropdown p={0}>{content}</Popover.Dropdown>
</Popover>
)
}