chore: perauset customer project
This commit is contained in:
282
apps/app/src/components/notification-bell.tsx
Normal file
282
apps/app/src/components/notification-bell.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user