Notification UI and house invitation

This commit is contained in:
2026-02-19 19:16:26 +07:00
parent 84ed1e6c21
commit fa689ea4aa
35 changed files with 2592 additions and 112 deletions

View File

@@ -1,19 +1,7 @@
import { Separator } from '@base-ui/react/separator';
import { m } from '@paraglide/messages';
import { BellIcon } from '@phosphor-icons/react';
import { Badge } from '@ui/badge';
import { Button } from '@ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@ui/dropdown-menu';
import { SidebarTrigger } from '@ui/sidebar';
import { useAuth } from './auth/auth-provider';
import Notification from './Notification';
import RouterBreadcrumb from './sidebar/router-breadcrumb';
export default function Header() {
@@ -30,48 +18,7 @@ export default function Header() {
/>
<RouterBreadcrumb />
</div>
<div className="flex mr-2">
{session?.user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg" variant="ghost" className="relative">
<BellIcon size={32} />
{false && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
>
0
</Badge>
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-sm min-w-56 rounded-lg">
<DropdownMenuLabel className="font-bold text-black">
{m.ui_label_notifications()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">System</p>
<p className="text-xs text-muted-foreground">
1 hour ago
</p>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
{m.ui_view_all_notifications()}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex mr-2">{session?.user && <Notification />}</div>
</header>
</>
);

View File

@@ -0,0 +1,83 @@
import { notificationQueries } from '@/service/queries';
import { formatTimeAgo } from '@/utils/helper';
import { cn } from '@lib/utils';
import { m } from '@paraglide/messages';
import { BellIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Button } from '@ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@ui/dropdown-menu';
import { Item, ItemContent, ItemDescription, ItemTitle } from './ui/item';
const Notification = () => {
const { data: notifications } = useQuery(notificationQueries.topFive());
if (!notifications) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="ghost"
className="relative rounded-full"
>
<BellIcon
size={32}
className={cn('origin-top', { 'animate-bell-ring': true })}
/>
{true && (
<span className="absolute top-1 right-1 rounded-full w-2 h-2 bg-red-600"></span>
)}
<span className="sr-only">{m.ui_label_notifications()}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-sm min-w-56 rounded-lg">
<DropdownMenuLabel className="font-bold text-black">
{m.ui_label_notifications()}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{notifications.map((notify) => {
return (
<DropdownMenuItem key={notify.id}>
<Item className="p-0">
<ItemContent>
<ItemTitle className="text-sm">
{m.templates_title_notification({
title: notify.title as Parameters<
typeof m.templates_title_notification
>[0]['title'],
})}
</ItemTitle>
<ItemDescription>
{formatTimeAgo(new Date(notify.createdAt))}
</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/management/notifications" className="cursor-pointer">
{m.ui_view_all_notifications()}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default Notification;

View File

@@ -38,7 +38,7 @@ const UserInviteMemberForm = ({ onSubmit }: FormProps) => {
});
onSubmit(false);
refetch();
toast.success(m.houses_page_message_create_house_success(), {
toast.success(m.houses_page_message_invite_member_success(), {
richColors: true,
});
},

View File

@@ -1,3 +1,4 @@
import useHasPermission from '@/hooks/use-has-permission';
import { cancelInvitation } from '@/service/house.api';
import { INVITE_STATUS } from '@/types/enum';
import { authClient } from '@lib/auth-client';
@@ -23,13 +24,17 @@ type InvitationListProps = {
const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
const { refetch } = authClient.useActiveOrganization();
const { hasPermission, isLoading } = useHasPermission(
'invitation',
'cancel',
true,
);
const { mutate: cancelInvitationMutation } = useMutation({
mutationFn: cancelInvitation,
onSuccess: () => {
refetch();
// _setOpen(false);
toast.success(m.houses_page_message_delete_house_success(), {
toast.success(m.houses_page_message_cancel_invitation_success(), {
richColors: true,
});
},
@@ -66,7 +71,7 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
<TableBody>
{activeHouse.invitations.length > 0 ? (
activeHouse.invitations.map((item) => (
<TableRow>
<TableRow key={item.id}>
<TableCell>
<Item>
<ItemContent>
@@ -87,7 +92,7 @@ const CurrentUserInvitationList = ({ activeHouse }: InvitationListProps) => {
</TableCell>
<TableCell className="p-6">
<div className="flex justify-end gap-2">
{item.status !== INVITE_STATUS.CANCELED && (
{item.status === INVITE_STATUS.PENDING && hasPermission && (
<Button
variant="outline"
className="cursor-pointer w-20 py-4"

View File

@@ -18,7 +18,7 @@ type ActionProps = {};
const InviteUserAction = ({}: ActionProps) => {
const { hasPermission, isLoading } = useHasPermission(
'house',
'invitation',
'create',
true,
);

View File

@@ -0,0 +1,59 @@
import { NOTIFICATION_TYPE } from '@/types/enum';
import { cn } from '@lib/utils';
import { m } from '@paraglide/messages';
import { Item, ItemHeader } from '@ui/item';
import { cva, VariantProps } from 'class-variance-authority';
import NotificationInvitation from './notification-type/invitation';
type NotifyProps = {
notify: NotificationWithUser;
};
const notifyVariants = cva('bg-linear-to-br shadow-xs', {
variants: {
variant: {
invitation: 'to-gray-50 from-teal-50',
system: '',
error: '',
house: '',
expired: '',
},
},
defaultVariants: {
variant: 'invitation',
},
});
const NotificationItem = ({
className,
notify,
...props
}: NotifyProps & React.ComponentProps<'div'>) => {
return (
<Item
variant="outline"
className={cn(
notifyVariants({
variant: notify.type as VariantProps<
typeof notifyVariants
>['variant'],
className,
}),
)}
{...props}
>
<ItemHeader>
{m.templates_title_notification({
title: notify.title as Parameters<
typeof m.templates_title_notification
>[0]['title'],
})}
</ItemHeader>
{notify.type === NOTIFICATION_TYPE.INVITATION && (
<NotificationInvitation notify={notify} />
)}
</Item>
);
};
export default NotificationItem;

View File

@@ -0,0 +1,110 @@
import { Button } from '@/components/ui/button';
import {
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@/components/ui/item';
import { m } from '@/paraglide/messages';
import { acceptInvitation, rejectInvitation } from '@/service/house.api';
import { notificationQueries } from '@/service/queries';
import { formatTimeAgo } from '@/utils/helper';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
type NotifyProps = {
notify: NotificationWithUser;
};
const NotificationInvitation = ({ notify }: NotifyProps) => {
const { house } = JSON.parse(notify.metadata || '');
const queryClient = useQueryClient();
const { mutate: acceptInvitationMutation } = useMutation({
mutationFn: acceptInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_success(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const { mutate: rejectInvitationMutation } = useMutation({
mutationFn: rejectInvitation,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [...notificationQueries.all, 'list'],
});
toast.success(m.notification_page_message_invitation_rejected(), {
richColors: true,
});
},
onError: (error: ReturnError) => {
console.error(error);
const code = error.code as Parameters<
typeof m.backend_message
>[0]['code'];
toast.error(m.backend_message({ code }), {
richColors: true,
});
},
});
const handleAgreeAction = async () => {
if (notify.link) {
acceptInvitationMutation({
data: { id: notify.link, notificationId: notify.id },
});
}
};
const handleRejectAction = () => {
if (notify.link) {
rejectInvitationMutation({
data: { id: notify.link, notificationId: notify.id },
});
}
};
return (
<>
<ItemContent>
<ItemTitle>
{`${notify.user.name} (${notify.user.email})`}{' '}
{m.templates_message_notification({
message: notify.message as Parameters<
typeof m.templates_message_notification
>[0]['message'],
name: house.name,
})}
</ItemTitle>
<ItemDescription>{formatTimeAgo(notify.createdAt)}</ItemDescription>
</ItemContent>
{notify.link && (
<ItemActions>
<Button onClick={() => handleAgreeAction()}>
{m.ui_agree_btn()}
</Button>
<Button variant="outline" onClick={() => handleRejectAction()}>
{m.ui_reject_btn()}
</Button>
</ItemActions>
)}
</>
);
};
export default NotificationInvitation;

View File

@@ -25,7 +25,7 @@ const AddNewUserButton = () => {
return (
<Dialog open={_open} onOpenChange={_setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default">
<Button type="button" variant="default" className="cursor-pointer">
<PlusIcon />
{m.nav_add_new()}
</Button>