B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1 @@
export * from './notifications-popover';

View File

@@ -0,0 +1,249 @@
'use client';
import { useCallback, useEffect, 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 { If } from '@kit/ui/if';
import { Popover, PopoverContent, PopoverTrigger } from '@kit/ui/popover';
import { Separator } from '@kit/ui/separator';
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) => {
const unique = new Set(existing.map((notification) => notification.id));
const notificationsFiltered = notifications.filter(
(notification) => !unique.has(notification.id),
);
return [...notificationsFiltered, ...existing];
});
},
[],
);
const dismissNotification = useDismissNotification();
useFetchNotifications({
onNotifications,
accountIds: params.accountIds,
realtime: params.realtime,
});
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);
};
useEffect(() => {
return () => {
setNotifications([]);
};
}, []);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button className={'relative h-9 w-9'} variant={'ghost'}>
<Bell className={'min-h-4 min-w-4'} />
<span
className={cn(
`fade-in animate-in zoom-in absolute right-1 top-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
{
hidden: !notifications.length,
},
)}
>
{notifications.length}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'}
align={'start'}
collisionPadding={20}
sideOffset={10}
>
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
{t('common:notifications')}
</div>
<Separator />
<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 gap-y-1 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 gap-x-3 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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './use-fetch-notifications';
export * from './use-dismiss-notification';

View File

@@ -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],
);
}

View File

@@ -0,0 +1,73 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useNotificationsStream } from './use-notifications-stream';
type Notification = {
id: number;
body: string;
dismissed: boolean;
type: 'info' | 'warning' | 'error';
created_at: string;
link: string | null;
};
export function useFetchNotifications({
onNotifications,
accountIds,
realtime,
}: {
onNotifications: (notifications: Notification[]) => unknown;
accountIds: string[];
realtime: boolean;
}) {
const { data: initialNotifications } = useFetchInitialNotifications({
accountIds,
});
useNotificationsStream({
onNotifications,
accountIds,
enabled: realtime,
});
useEffect(() => {
if (initialNotifications) {
onNotifications(initialNotifications);
}
}, [initialNotifications, onNotifications]);
}
function useFetchInitialNotifications(props: { accountIds: string[] }) {
const client = useSupabase();
const now = new Date().toISOString();
return useQuery({
queryKey: ['notifications', ...props.accountIds],
queryFn: async () => {
const { data } = await client
.from('notifications')
.select(
`id,
body,
dismissed,
type,
created_at,
link
`,
)
.in('account_id', props.accountIds)
.eq('dismissed', false)
.gt('expires_at', now)
.order('created_at', { ascending: false })
.limit(10);
return data;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
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;
};
export function useNotificationsStream(params: {
onNotifications: (notifications: Notification[]) => void;
accountIds: string[];
enabled: boolean;
}) {
const client = useSupabase();
const { data: subscription } = useQuery({
enabled: params.enabled,
queryKey: ['realtime-notifications', ...params.accountIds],
queryFn: () => {
const channel = client.channel('notifications-channel');
return channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
filter: `account_id=in.(${params.accountIds.join(', ')})`,
table: 'notifications',
},
(payload) => {
params.onNotifications([payload.new as Notification]);
},
)
.subscribe();
},
});
useEffect(() => {
return () => {
void subscription?.unsubscribe();
};
}, [subscription]);
}

View 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);
}
}

View File

@@ -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;
}
}
}