283 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|