finished header notifications

This commit is contained in:
smanylov
2026-04-04 16:14:56 +07:00
parent b6c5278282
commit 27ca7ddf8e
10 changed files with 346 additions and 162 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "no-copy-frontend", "name": "no-copy-frontend",
"version": "0.78.0", "version": "0.79.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 2999", "dev": "next dev -p 2999",
+130
View File
@@ -0,0 +1,130 @@
'use server'
import { getSessionData } from '@/app/actions/session';
import { API_BASE_URL } from '@/app/actions/definitions';
export interface NotificationBody {
createdAt: string | null,
id: number,
message: string,
status: string | null,
type: string | null
}
export async function fetchActiveNotifications() {
const token = await getSessionData('token');
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30015,
message_body: {
action: 'active',
authToken: token,
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_body) {
return parsed?.message_body
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return false
}
}
export async function fetchAllNotifications() {
const token = await getSessionData('token');
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30015,
message_body: {
action: 'list',
authToken: token,
page: 0,
size: 10,
sortBy: "createdAt",
sortDirection: "DESC",
types: [], //"SEARCH_RESULT", "MONITORING_RESULT"
statuses: [] //"NEW"
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_body) {
return parsed?.message_body
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return false
}
}
export async function notificationsMarkAsRead(ids: number[]) {
const token = await getSessionData('token');
try {
const response = await fetch(`${API_BASE_URL}/api/v1/data`, {
method: 'POST',
body: JSON.stringify({
version: 1,
msg_id: 30015,
message_body: {
action: 'markRead',
authToken: token,
notificationIds: ids
}
}),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (response.ok) {
let parsed = await response.json();
if (parsed?.message_body) {
return parsed?.message_body
} else {
throw parsed;
}
} else {
throw (`${response.status}`);
}
} catch (error) {
return {
success: false
}
}
}
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { fetchActiveNotifications, NotificationBody } from '@/app/actions/notificationActions';
export interface UseActiveNotifications {
notifications: NotificationBody[],
unreadCount: number
}
export const useActiveNotifications = () => {
return useQuery({
queryKey: ['activeNotifications'],
queryFn: () => {
return fetchActiveNotifications()
},
select: (data: UseActiveNotifications | null) => {
if (!data) {
return {
notifications: [],
unreadCount: 0
}
}
return data;
},
retry: false,
refetchInterval: 10000
});
};
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { fetchAllNotifications, NotificationBody } from '@/app/actions/notificationActions';
export interface UseAllNotifications {
notifications: NotificationBody[],
unreadCount: number
}
export const useAllNotifications = () => {
return useQuery({
queryKey: ['allNotifications'],
queryFn: () => {
return fetchAllNotifications()
},
select: (data: UseAllNotifications | null) => {
if (!data) {
return {
notifications: [],
unreadCount: 0
}
}
return data;
},
retry: false,
refetchInterval: 10000
});
};
+33 -13
View File
@@ -148,6 +148,10 @@
color: v.$p-color; color: v.$p-color;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
&:hover {
color: v.$p-color-hover;
}
} }
&-list { &-list {
@@ -161,7 +165,10 @@
padding: 5px 10px 5px 10px; padding: 5px 10px 5px 10px;
border-bottom: 1px solid v.$b-color-1; border-bottom: 1px solid v.$b-color-1;
transition: background-color 0.2s; transition: background-color 0.2s;
cursor: pointer;
&.loading {
opacity: 0.5;
}
} }
&-title { &-title {
@@ -180,17 +187,6 @@
align-items: center; */ align-items: center; */
} }
&-icon {
font-size: 24px;
flex-shrink: 0;
display: flex;
align-items: center;
.icon {
color: v.$p-color;
}
}
&-text { &-text {
font-size: 13px; font-size: 13px;
color: v.$text-s; color: v.$text-s;
@@ -198,7 +194,8 @@
font-weight: 500; font-weight: 500;
} }
&-data { &-data,
&-mark-as-read {
font-size: 10px; font-size: 10px;
color: v.$text-m; color: v.$text-m;
display: flex; display: flex;
@@ -208,10 +205,25 @@
right: 0px; right: 0px;
} }
&-mark-as-read {
font-size: 12px;
bottom: 0px;
top: auto;
cursor: pointer;
height: max-content;
color: v.$p-color;
&:hover {
color: v.$p-color-hover;
}
}
&-footer { &-footer {
padding: 12px 20px; padding: 12px 20px;
border-top: 1px solid v.$b-color-1; border-top: 1px solid v.$b-color-1;
text-align: center; text-align: center;
position: relative;
.notification-link { .notification-link {
color: v.$p-color; color: v.$p-color;
@@ -219,6 +231,14 @@
font-weight: 500; font-weight: 500;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
&:disabled {
opacity: 0.5;
}
&:hover {
color: v.$p-color-hover;
}
} }
} }
} }
+1
View File
@@ -1,4 +1,5 @@
$p-color: #6366f1; $p-color: #6366f1;
$p-color-hover: #1e0ff0;
$s-color: #8b5cf6; $s-color: #8b5cf6;
$text-p: #1f2937; $text-p: #1f2937;
$text-s: #4b5563; $text-s: #4b5563;
+84 -63
View File
@@ -5,55 +5,24 @@ import { useClickOutside } from '@/app/hooks/useClickOutside';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { IconNotification } from '@/app/ui/icons/icons'; import { IconNotification } from '@/app/ui/icons/icons';
import { useActiveNotifications } from '@/app/hooks/react-query/useActiveNotificationsList';
interface Notification { import { NotificationBody, notificationsMarkAsRead } from '@/app/actions/notificationActions';
id: string, import { useQueryClient } from '@tanstack/react-query';
notificationText: string,
data: string,
link: string,
}
export default function NotificationsButton() { export default function NotificationsButton() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [notificationData, setNotificationData] = useState<Notification[] | []>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
const [loadingIds, setLoadingIds] = useState<Set<number>>(new Set());
const [isMarkingAll, setIsMarkingAll] = useState(false);
const t = useTranslations('Global'); const t = useTranslations('Global');
const { data: notificationList } = useActiveNotifications();
const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
setNotificationData([ console.log(notificationList);
{ }, [notificationList])
id: '1',
notificationText: 'text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1',
data: '20.20.20',
link: '#'
},
{
id: '2',
notificationText: 'text2',
data: '20.20.20',
link: '#'
},
{
id: '3',
notificationText: 'text3',
data: '20.20.20',
link: '#'
},
{
id: '4',
notificationText: 'text4',
data: '20.20.20',
link: '#'
},
{
id: '5',
notificationText: 'text5',
data: '20.20.20',
link: '#'
}
])
}, [])
useClickOutside( useClickOutside(
menuRef, menuRef,
@@ -71,12 +40,42 @@ export default function NotificationsButton() {
} }
}; };
function markAsReadHandler() { async function markAsReadHandler(ids: number[]) {
console.log('click: mark as read'); setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await notificationsMarkAsRead(ids);
if (response.success) {
await queryClient.invalidateQueries({ queryKey: ['activeNotifications'] });
await queryClient.invalidateQueries({ queryKey: ['allNotifications'] });
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to mark as read');
} finally {
setLoadingIds(prev => {
const newSet = new Set(prev);
ids.forEach(id => newSet.delete(id));
return newSet;
});
}
} }
function notificationClickHandler(id: string) { async function markAllAsReadHandler() {
console.log(`click: ${id}`); const array = notificationList?.notifications?.map(e => e.id) ?? [];
if (array.length === 0) return;
setIsMarkingAll(true);
try {
await markAsReadHandler(array);
} finally {
setIsMarkingAll(false);
}
} }
return ( return (
@@ -107,28 +106,23 @@ export default function NotificationsButton() {
setIsOpen(false); setIsOpen(false);
}} }}
> >
{t('no-new')} / {t('all')} {notificationList?.unreadCount ? notificationList?.unreadCount : t('no-new')} / {t('all')}
</Link> </Link>
</div> </div>
{notificationData.length !== 0 ? ( {notificationList?.notifications.length !== 0 ? (
<>
<div <div
className="notification-list" className="notification-list"
> >
{notificationData.map((item) => { {notificationList?.notifications?.map((item) => {
return ( return (
<div <div
key={item.id} key={item.id}
className="notification-item" className={`notification-item ${loadingIds.has(item.id) ? 'loading' : ''}`}
onClick={() => {
notificationClickHandler(item.id);
}}
> >
<div className="notification-icon">
<IconNotification />
</div>
<div className="notification-content"> <div className="notification-content">
<div className="notification-data"> <div className="notification-data">
{item.data} {item.createdAt ? item.createdAt : '#'}
</div> </div>
{/* <div className="notification-title"> {/* <div className="notification-title">
@@ -136,29 +130,56 @@ export default function NotificationsButton() {
</div> */} </div> */}
<div className="notification-text"> <div className="notification-text">
{item.notificationText} {item.message}
</div> </div>
<button
className="notification-mark-as-read"
onClick={() => {
markAsReadHandler([item.id]);
}}
disabled={loadingIds.has(item.id)}
>
{t('mark-as-read')}
</button>
{loadingIds.has(item.id) && (
<div className="loading-animation">
<div className="global-spinner"></div>
</div>
)}
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
) : (
<p>
{/* {t('no-data')} */}
</p>
)}
<div className="notification-footer"> <div className="notification-footer">
<button <button
className="notification-link" className="notification-link"
onClick={() => { onClick={() => {
markAsReadHandler(); markAllAsReadHandler()
}} }}
disabled={isMarkingAll || loadingIds.size > 0}
> >
{isMarkingAll || loadingIds.size > 0 && (
<div className="loading-animation">
<div className="global-spinner"></div>
</div>
)}
{t('mark-all-as-read')} {t('mark-all-as-read')}
</button> </button>
</div> </div>
</> </>
) : (
<p
className="text-center mb-4 mt-4"
>
{t('no-new-notifications')}
</p>
)}
</>
)} )}
</div> </div>
</div> </div>
+15 -61
View File
@@ -2,65 +2,19 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { IconCheck } from '@/app/ui/icons/icons'; import { IconCheck } from '@/app/ui/icons/icons';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useAllNotifications } from '@/app/hooks/react-query/useAllNotificationsList';
interface Notification {
id: string,
notificationText: string,
data: string,
link: string,
title: string,
status: string,
}
const noitficationList: Notification[] = [
{
id: '1',
notificationText: 'text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1 text1',
data: '20.20.20',
link: '#',
title: 'title 1',
status: 'status'
},
{
id: '2',
notificationText: 'text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 text2 ',
data: '20.20.20',
link: '#',
title: 'title 2',
status: 'status'
},
{
id: '3',
notificationText: 'text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 text3 ',
data: '20.20.20',
link: '#',
title: 'title 3',
status: 'status'
},
{
id: '4',
notificationText: 'text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 text4 ',
data: '20.20.20',
link: '#',
title: 'title 4',
status: 'status'
},
{
id: '5',
notificationText: 'text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 text5 ',
data: '20.20.20',
link: '#',
title: 'title 5',
status: 'status'
}
];
export function NotificationsList() { export function NotificationsList() {
const t = useTranslations('Global'); const t = useTranslations('Global');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
const { data: allNotifications } = useAllNotifications();
function selectHandler(fileId: string) { useEffect(() => {
console.log(allNotifications);
}, [allNotifications])
function selectHandler(fileId: number) {
setSelectedFiles((prev) => { setSelectedFiles((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -78,7 +32,7 @@ export function NotificationsList() {
} }
function selectAllHandler() { function selectAllHandler() {
setSelectedFiles(new Set(noitficationList.map(item => item.id))); setSelectedFiles(new Set(allNotifications?.notifications.map(item => item.id)));
} }
return ( return (
@@ -129,8 +83,8 @@ export function NotificationsList() {
<div <div
className="notifications-list-body" className="notifications-list-body"
> >
{(noitficationList.length !== 0) && ( {(allNotifications?.notifications.length !== 0) && (
noitficationList.map(item => { allNotifications?.notifications.map(item => {
return ( return (
<div <div
className="notification-item" className="notification-item"
@@ -159,7 +113,7 @@ export function NotificationsList() {
<div <div
className="notification-title" className="notification-title"
> >
{item.title} {item.type}
</div> </div>
<div <div
className="notification-status" className="notification-status"
@@ -170,7 +124,7 @@ export function NotificationsList() {
<div <div
className="notification-text" className="notification-text"
> >
{item.notificationText} {item.message}
</div> </div>
<div <div
className="notification-footer" className="notification-footer"
@@ -178,7 +132,7 @@ export function NotificationsList() {
<div <div
className="notification-date" className="notification-date"
> >
{item.data} {item.createdAt}
</div> </div>
<button <button
+3 -1
View File
@@ -385,6 +385,7 @@
"all-statuses": "All statuses", "all-statuses": "All statuses",
"all": "All", "all": "All",
"mark-all-as-read": "Mark all as read", "mark-all-as-read": "Mark all as read",
"mark-as-read": "Mark as read",
"all-notifications": "All notifications", "all-notifications": "All notifications",
"deselect": "Deselect", "deselect": "Deselect",
"select-all": "Select all", "select-all": "Select all",
@@ -400,7 +401,8 @@
"when-you-pay-for-a-subscription-your-card-is-saved-for-automatic-renewal": "When you pay for a subscription, your card is saved for automatic renewal.", "when-you-pay-for-a-subscription-your-card-is-saved-for-automatic-renewal": "When you pay for a subscription, your card is saved for automatic renewal.",
"the-debit-occurs-1-day-before-the-end-of-the-subscription": "The debit occurs 1 day before the end of the subscription.", "the-debit-occurs-1-day-before-the-end-of-the-subscription": "The debit occurs 1 day before the end of the subscription.",
"you-can-unlink-your-card-and-cancel-auto-renewal-at-any-time": "You can unlink your card and cancel auto-renewal at any time.", "you-can-unlink-your-card-and-cancel-auto-renewal-at-any-time": "You can unlink your card and cancel auto-renewal at any time.",
"delete-a-bank-card": "Delete a bank card" "delete-a-bank-card": "Delete a bank card",
"no-new-notifications": "There are no new notifications"
}, },
"Login-register-form": { "Login-register-form": {
"and": "and", "and": "and",
+3 -1
View File
@@ -385,6 +385,7 @@
"all-statuses": "Все статусы", "all-statuses": "Все статусы",
"all": "Все", "all": "Все",
"mark-all-as-read": "Отметить все как прочитанное", "mark-all-as-read": "Отметить все как прочитанное",
"mark-as-read": "Отметить как прочитанное",
"all-notifications": "Все уведомления", "all-notifications": "Все уведомления",
"deselect": "Снять выделение", "deselect": "Снять выделение",
"select-all": "Выбрать все", "select-all": "Выбрать все",
@@ -400,7 +401,8 @@
"when-you-pay-for-a-subscription-your-card-is-saved-for-automatic-renewal": "При оплате подписки ваша карта сохраняется для автоматического продления.", "when-you-pay-for-a-subscription-your-card-is-saved-for-automatic-renewal": "При оплате подписки ваша карта сохраняется для автоматического продления.",
"the-debit-occurs-1-day-before-the-end-of-the-subscription": "Списание происходит за 1 день до окончания подписки.", "the-debit-occurs-1-day-before-the-end-of-the-subscription": "Списание происходит за 1 день до окончания подписки.",
"you-can-unlink-your-card-and-cancel-auto-renewal-at-any-time": "Вы можете отвязать карту и отменить автопродление в любой момент.", "you-can-unlink-your-card-and-cancel-auto-renewal-at-any-time": "Вы можете отвязать карту и отменить автопродление в любой момент.",
"delete-a-bank-card": "Удалить банковскую карту" "delete-a-bank-card": "Удалить банковскую карту",
"no-new-notifications": "Новых уведомлений нет"
}, },
"Login-register-form": { "Login-register-form": {
"and": "и", "and": "и",