Add notifications feature and update feature flags
This update includes creating new files for the notifications feature along with adding two feature flags for enabling notifications and realtime notifications. All the code and package dependencies required for the notifications functionality have been added. The 'pnpm-lock.yaml' has also been updated due to the inclusion of new package dependencies.
This commit is contained in:
45
packages/features/notifications/package.json
Normal file
45
packages/features/notifications/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@kit/notifications",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./hooks": "./src/hooks/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.42.7",
|
||||
"@types/react": "^18.3.1",
|
||||
"lucide-react": "^0.376.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^14.1.1"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/features/notifications/src/components/index.ts
Normal file
1
packages/features/notifications/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './notifications-popover';
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Divider } from '@kit/ui/divider';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { useDismissNotification, useFetchNotifications } from '../hooks';
|
||||
|
||||
type Notification = Database['public']['Tables']['notifications']['Row'];
|
||||
|
||||
type PartialNotification = Pick<
|
||||
Notification,
|
||||
'id' | 'body' | 'dismissed' | 'type' | 'created_at' | 'link'
|
||||
>;
|
||||
|
||||
export function NotificationsPopover(params: {
|
||||
realtime: boolean;
|
||||
accountIds: string[];
|
||||
onClick?: (notification: PartialNotification) => void;
|
||||
}) {
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<PartialNotification[]>([]);
|
||||
|
||||
const onNotifications = useCallback(
|
||||
(notifications: PartialNotification[]) => {
|
||||
setNotifications((existing) => [...notifications, ...existing]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dismissNotification = useDismissNotification();
|
||||
|
||||
useFetchNotifications({
|
||||
onNotifications,
|
||||
accountIds: params.accountIds,
|
||||
realtime: params.realtime,
|
||||
});
|
||||
|
||||
const unread = notifications.filter(
|
||||
(notification) => !notification.dismissed,
|
||||
);
|
||||
|
||||
const timeAgo = (createdAt: string) => {
|
||||
const date = new Date(createdAt);
|
||||
|
||||
let time: number;
|
||||
|
||||
const daysAgo = Math.floor(
|
||||
(new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
|
||||
if (daysAgo < 1) {
|
||||
time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60));
|
||||
|
||||
if (time < 5) {
|
||||
return t('common:justNow');
|
||||
}
|
||||
|
||||
if (time < 60) {
|
||||
return formatter.format(-time, 'minute');
|
||||
}
|
||||
|
||||
const hours = Math.floor(time / 60);
|
||||
|
||||
return formatter.format(-hours, 'hour');
|
||||
}
|
||||
|
||||
const unit = (() => {
|
||||
const minutesAgo = Math.floor(
|
||||
(new Date().getTime() - date.getTime()) / (1000 * 60),
|
||||
);
|
||||
|
||||
if (minutesAgo <= 60) {
|
||||
return 'minute';
|
||||
}
|
||||
|
||||
if (daysAgo <= 1) {
|
||||
return 'hour';
|
||||
}
|
||||
|
||||
if (daysAgo <= 30) {
|
||||
return 'day';
|
||||
}
|
||||
|
||||
if (daysAgo <= 365) {
|
||||
return 'month';
|
||||
}
|
||||
|
||||
return 'year';
|
||||
})();
|
||||
|
||||
const text = formatter.format(-daysAgo, unit);
|
||||
|
||||
return text.slice(0, 1).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className={'h-8 w-8'} variant={'ghost'}>
|
||||
<Bell className={'min-h-5 min-w-5'} />
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
`fade-in animate-in zoom-in absolute right-5 top-5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
|
||||
{
|
||||
hidden: !unread.length,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{unread.length}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className={'flex flex-col p-0'}
|
||||
collisionPadding={{ right: 20 }}
|
||||
>
|
||||
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
|
||||
{t('common:notifications')}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<If condition={!notifications.length}>
|
||||
<div className={'px-3 py-2 text-sm'}>
|
||||
{t('common:noNotifications')}
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800'
|
||||
}
|
||||
>
|
||||
{notifications.map((notification) => {
|
||||
const maxChars = 100;
|
||||
|
||||
let body = t(notification.body, {
|
||||
defaultValue: notification.body,
|
||||
});
|
||||
|
||||
if (body.length > maxChars) {
|
||||
body = body.substring(0, maxChars) + '...';
|
||||
}
|
||||
|
||||
const Icon = () => {
|
||||
switch (notification.type) {
|
||||
case 'warning':
|
||||
return <TriangleAlert className={'h-4 text-yellow-500'} />;
|
||||
case 'error':
|
||||
return <CircleAlert className={'text-destructive h-4'} />;
|
||||
default:
|
||||
return <Info className={'h-4 text-blue-500'} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id.toString()}
|
||||
className={cn(
|
||||
'min-h-18 flex flex-col items-start justify-center space-y-0.5 px-3 py-2',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (params.onClick) {
|
||||
params.onClick(notification);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={'flex w-full items-start justify-between'}>
|
||||
<div
|
||||
className={'flex items-start justify-start space-x-2 py-2'}
|
||||
>
|
||||
<div className={'py-0.5'}>
|
||||
<Icon />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'text-sm'}>
|
||||
<If condition={notification.link} fallback={body}>
|
||||
{(link) => (
|
||||
<a href={link} className={'hover:underline'}>
|
||||
{body}
|
||||
</a>
|
||||
)}
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground text-xs'}>
|
||||
{timeAgo(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'py-2'}>
|
||||
<Button
|
||||
className={'max-h-6 max-w-6'}
|
||||
size={'icon'}
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
setNotifications((existing) => {
|
||||
return existing.filter(
|
||||
(existingNotification) =>
|
||||
existingNotification.id !== notification.id,
|
||||
);
|
||||
});
|
||||
|
||||
return dismissNotification(notification.id);
|
||||
}}
|
||||
>
|
||||
<XIcon className={'h-3'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
2
packages/features/notifications/src/hooks/index.ts
Normal file
2
packages/features/notifications/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './use-fetch-notifications';
|
||||
export * from './use-dismiss-notification';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
export function useDismissNotification() {
|
||||
const client = useSupabase();
|
||||
|
||||
return useCallback(
|
||||
async (notification: number) => {
|
||||
const { error } = await client
|
||||
.from('notifications')
|
||||
.update({ dismissed: true })
|
||||
.eq('id', notification);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[client],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
|
||||
type Notification = {
|
||||
id: number;
|
||||
body: string;
|
||||
dismissed: boolean;
|
||||
type: 'info' | 'warning' | 'error';
|
||||
created_at: string;
|
||||
link: string | null;
|
||||
entity_id: string | null;
|
||||
entity_type: string | null;
|
||||
};
|
||||
|
||||
export function useFetchNotifications({
|
||||
onNotifications,
|
||||
accountIds,
|
||||
realtime,
|
||||
}: {
|
||||
onNotifications: (notifications: Notification[]) => unknown;
|
||||
accountIds: string[];
|
||||
realtime: boolean;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const didFetchInitialData = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let realtimeSubscription: { unsubscribe: () => void } | null = null;
|
||||
|
||||
if (realtime) {
|
||||
const channel = client.channel('notifications-channel');
|
||||
|
||||
realtimeSubscription = channel
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
filter: `account_id=in.(${accountIds.join(', ')})`,
|
||||
table: 'notifications',
|
||||
},
|
||||
(payload) => {
|
||||
onNotifications([payload.new as Notification]);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
if (!didFetchInitialData.current) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const initialFetch = client
|
||||
.from('notifications')
|
||||
.select(
|
||||
`id,
|
||||
body,
|
||||
dismissed,
|
||||
type,
|
||||
created_at,
|
||||
link,
|
||||
entity_id,
|
||||
entity_type
|
||||
`,
|
||||
)
|
||||
.in('account_id', accountIds)
|
||||
.eq('dismissed', false)
|
||||
.gt('expires_at', now)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
didFetchInitialData.current = true;
|
||||
|
||||
void initialFetch.then(({ data, error }) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
onNotifications(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (realtimeSubscription) {
|
||||
realtimeSubscription.unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [client, onNotifications, accountIds, realtime]);
|
||||
}
|
||||
53
packages/features/notifications/src/server/api.ts
Normal file
53
packages/features/notifications/src/server/api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file API for notifications
|
||||
*
|
||||
* Usage
|
||||
*
|
||||
* ```typescript
|
||||
* import { createNotificationsApi } from '@kit/notifications/api';
|
||||
*
|
||||
* const api = createNotificationsApi(client);
|
||||
*
|
||||
* await api.createNotification({
|
||||
* body: 'Hello, world!',
|
||||
* account_id: '123',
|
||||
* type: 'info',
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createNotificationsService } from './notifications.service';
|
||||
|
||||
type Notification = Database['public']['Tables']['notifications'];
|
||||
|
||||
/**
|
||||
* @name createNotificationsApi
|
||||
* @param client
|
||||
*/
|
||||
export function createNotificationsApi(client: SupabaseClient<Database>) {
|
||||
return new NotificationsApi(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name NotificationsApi
|
||||
*/
|
||||
class NotificationsApi {
|
||||
private readonly service: ReturnType<typeof createNotificationsService>;
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {
|
||||
this.service = createNotificationsService(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createNotification
|
||||
* @description Create a new notification in the database
|
||||
* @param params
|
||||
*/
|
||||
createNotification(params: Notification['Insert']) {
|
||||
return this.service.createNotification(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Notification = Database['public']['Tables']['notifications'];
|
||||
|
||||
export function createNotificationsService(client: SupabaseClient<Database>) {
|
||||
return new NotificationsService(client);
|
||||
}
|
||||
|
||||
class NotificationsService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async createNotification(params: Notification['Insert']) {
|
||||
const { error } = await this.client.from('notifications').insert(params);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/features/notifications/tsconfig.json
Normal file
8
packages/features/notifications/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user