@goplusvn/core 0.1.52 → 0.1.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/features/README.md +5 -0
- package/features/notifications/README.md +14 -0
- package/features/notifications/migrations/0001_init.sql +59 -0
- package/features/notifications/schema.prisma +46 -0
- package/package.json +3 -1
- package/src/notification/__tests__/notification-service.test.ts +192 -0
- package/src/notification/__tests__/notification-ui.test.tsx +62 -0
- package/src/notification/index.ts +15 -13
- package/src/notification/notification-service.ts +270 -96
- package/src/notification/ui/index.ts +2 -0
- package/src/notification/ui/notification-bell.tsx +244 -0
- package/src/notification/ui/notifications-inbox.tsx +193 -0
- package/src/notification/storage/in-memory.ts +0 -56
- package/src/notification/storage/index.ts +0 -1
- package/src/notification/types.ts +0 -51
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { useParams, useRouter } from "next/navigation"
|
|
5
|
+
import { Badge, Button, DynamicIcon } from "../../ui/primitives"
|
|
6
|
+
import useSWRInfinite from "swr/infinite"
|
|
7
|
+
import { CheckCheck, Inbox } from "lucide-react"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Trang hộp thư thông báo đầy đủ (Phase 1 — bản gọn để "Xem tất cả" trong chuông
|
|
11
|
+
* hoạt động). Lọc tất cả/chưa đọc, tải thêm (cursor), đánh dấu đã đọc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface NotiItem {
|
|
15
|
+
id: string
|
|
16
|
+
type: string
|
|
17
|
+
category: string | null
|
|
18
|
+
title: string
|
|
19
|
+
content: string
|
|
20
|
+
url: string | null
|
|
21
|
+
iconName: string | null
|
|
22
|
+
isRead: boolean
|
|
23
|
+
createdAt: string
|
|
24
|
+
}
|
|
25
|
+
interface ListResp {
|
|
26
|
+
items: NotiItem[]
|
|
27
|
+
nextCursor: string | null
|
|
28
|
+
total: number
|
|
29
|
+
unreadCount: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fetcher = (url: string) =>
|
|
33
|
+
fetch(url).then((r) => {
|
|
34
|
+
if (!r.ok) throw new Error(String(r.status))
|
|
35
|
+
return r.json()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function timeAgo(d: string): string {
|
|
39
|
+
const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000)
|
|
40
|
+
if (s < 60) return "vừa xong"
|
|
41
|
+
const m = Math.floor(s / 60)
|
|
42
|
+
if (m < 60) return `${m} phút trước`
|
|
43
|
+
const h = Math.floor(m / 60)
|
|
44
|
+
if (h < 24) return `${h} giờ trước`
|
|
45
|
+
const day = Math.floor(h / 24)
|
|
46
|
+
if (day < 7) return `${day} ngày trước`
|
|
47
|
+
return new Date(d).toLocaleDateString("vi-VN")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const makeGetKey =
|
|
51
|
+
(filter: "all" | "unread") => (index: number, prev: ListResp | null) => {
|
|
52
|
+
if (prev && !prev.nextCursor) return null // hết trang
|
|
53
|
+
const cursor = index === 0 ? "" : prev?.nextCursor
|
|
54
|
+
return `/api/notifications?take=20&filter=${filter}${cursor ? `&cursor=${cursor}` : ""}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function NotificationsInbox() {
|
|
58
|
+
const router = useRouter()
|
|
59
|
+
const params = useParams()
|
|
60
|
+
const lang = (params?.lang as string) || "vi"
|
|
61
|
+
const [tab, setTab] = useState<"all" | "unread">("all")
|
|
62
|
+
|
|
63
|
+
// Hai bộ SWRInfinite riêng cho 2 tab → chuyển tab tức thì, không lẫn cache.
|
|
64
|
+
const all = useSWRInfinite<ListResp>(makeGetKey("all"), fetcher)
|
|
65
|
+
const unread = useSWRInfinite<ListResp>(makeGetKey("unread"), fetcher)
|
|
66
|
+
const swr = tab === "all" ? all : unread
|
|
67
|
+
|
|
68
|
+
const pages = swr.data ?? []
|
|
69
|
+
const items = pages.flatMap((p) => p.items)
|
|
70
|
+
const unreadCount = (all.data ?? [])[0]?.unreadCount ?? 0
|
|
71
|
+
const hasMore =
|
|
72
|
+
pages.length > 0 && pages[pages.length - 1]?.nextCursor != null
|
|
73
|
+
const loading = swr.isLoading || swr.isValidating
|
|
74
|
+
|
|
75
|
+
function refresh() {
|
|
76
|
+
void all.mutate()
|
|
77
|
+
void unread.mutate()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function markAllRead() {
|
|
81
|
+
await fetch("/api/notifications/read", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({ all: true }),
|
|
85
|
+
}).catch(() => {})
|
|
86
|
+
refresh()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function onItemClick(n: NotiItem) {
|
|
90
|
+
if (!n.isRead) {
|
|
91
|
+
await fetch("/api/notifications/read", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ ids: [n.id] }),
|
|
95
|
+
}).catch(() => {})
|
|
96
|
+
refresh()
|
|
97
|
+
}
|
|
98
|
+
if (n.url) router.push(`/${lang}${n.url}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
103
|
+
<div className="mb-4 flex items-center justify-between">
|
|
104
|
+
<div>
|
|
105
|
+
<h1 className="text-xl font-semibold">Thông báo</h1>
|
|
106
|
+
<p className="text-sm text-muted-foreground">
|
|
107
|
+
{unreadCount > 0
|
|
108
|
+
? `${unreadCount} thông báo chưa đọc`
|
|
109
|
+
: "Đã đọc hết"}
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
{unreadCount > 0 && (
|
|
113
|
+
<Button variant="outline" size="sm" onClick={markAllRead}>
|
|
114
|
+
<CheckCheck className="mr-1 size-4" />
|
|
115
|
+
Đánh dấu đã đọc tất cả
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="mb-3 flex gap-2">
|
|
121
|
+
<Button
|
|
122
|
+
variant={tab === "all" ? "default" : "ghost"}
|
|
123
|
+
size="sm"
|
|
124
|
+
onClick={() => setTab("all")}
|
|
125
|
+
>
|
|
126
|
+
Tất cả
|
|
127
|
+
</Button>
|
|
128
|
+
<Button
|
|
129
|
+
variant={tab === "unread" ? "default" : "ghost"}
|
|
130
|
+
size="sm"
|
|
131
|
+
onClick={() => setTab("unread")}
|
|
132
|
+
>
|
|
133
|
+
Chưa đọc
|
|
134
|
+
{unreadCount > 0 && <Badge className="ml-1">{unreadCount}</Badge>}
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="rounded-lg border border-border bg-card">
|
|
139
|
+
{items.length === 0 && !loading ? (
|
|
140
|
+
<div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
|
|
141
|
+
<Inbox className="size-8" />
|
|
142
|
+
<p className="text-sm">Chưa có thông báo</p>
|
|
143
|
+
</div>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className="divide-y divide-border">
|
|
146
|
+
{items.map((n) => (
|
|
147
|
+
<li key={n.id}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => onItemClick(n)}
|
|
151
|
+
className={`flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent hover:text-accent-foreground ${
|
|
152
|
+
n.isRead ? "" : "bg-primary/5"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
156
|
+
<DynamicIcon
|
|
157
|
+
name={(n.iconName as any) || "Bell"}
|
|
158
|
+
className="size-4"
|
|
159
|
+
/>
|
|
160
|
+
</span>
|
|
161
|
+
<span className="min-w-0 flex-1">
|
|
162
|
+
<span className="block text-sm font-medium">{n.title}</span>
|
|
163
|
+
<span className="mt-0.5 block text-sm text-muted-foreground">
|
|
164
|
+
{n.content}
|
|
165
|
+
</span>
|
|
166
|
+
<span className="mt-1 block text-xs text-muted-foreground">
|
|
167
|
+
{timeAgo(n.createdAt)}
|
|
168
|
+
</span>
|
|
169
|
+
</span>
|
|
170
|
+
{!n.isRead && (
|
|
171
|
+
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
|
|
172
|
+
)}
|
|
173
|
+
</button>
|
|
174
|
+
</li>
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{hasMore && (
|
|
181
|
+
<div className="mt-4 flex justify-center">
|
|
182
|
+
<Button
|
|
183
|
+
variant="outline"
|
|
184
|
+
onClick={() => swr.setSize(swr.size + 1)}
|
|
185
|
+
disabled={loading}
|
|
186
|
+
>
|
|
187
|
+
{loading ? "Đang tải…" : "Tải thêm"}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { Notification, NotificationStorage } from "../types";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* In-memory notification storage for development/testing
|
|
5
|
-
* In production, replace with database storage
|
|
6
|
-
*/
|
|
7
|
-
export class InMemoryStorage implements NotificationStorage {
|
|
8
|
-
private notifications = new Map<string, Notification>();
|
|
9
|
-
|
|
10
|
-
async save(notification: Notification): Promise<void> {
|
|
11
|
-
this.notifications.set(notification.id, notification);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async findByUserId(
|
|
15
|
-
userId: string,
|
|
16
|
-
options?: { unreadOnly?: boolean },
|
|
17
|
-
): Promise<Notification[]> {
|
|
18
|
-
const result: Notification[] = [];
|
|
19
|
-
|
|
20
|
-
for (const notification of this.notifications.values()) {
|
|
21
|
-
if (notification.userId === userId) {
|
|
22
|
-
if (options?.unreadOnly && notification.status === "read") {
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
result.push(notification);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async findById(id: string): Promise<Notification | null> {
|
|
33
|
-
return this.notifications.get(id) || null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async markAsRead(id: string): Promise<void> {
|
|
37
|
-
const notification = this.notifications.get(id);
|
|
38
|
-
if (notification) {
|
|
39
|
-
notification.status = "read";
|
|
40
|
-
notification.readAt = new Date();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async markAllAsRead(userId: string): Promise<void> {
|
|
45
|
-
for (const notification of this.notifications.values()) {
|
|
46
|
-
if (notification.userId === userId && notification.status !== "read") {
|
|
47
|
-
notification.status = "read";
|
|
48
|
-
notification.readAt = new Date();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async delete(id: string): Promise<void> {
|
|
54
|
-
this.notifications.delete(id);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { InMemoryStorage } from "./in-memory";
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// Notification Types
|
|
2
|
-
export type NotificationType =
|
|
3
|
-
| "info"
|
|
4
|
-
| "warning"
|
|
5
|
-
| "error"
|
|
6
|
-
| "success"
|
|
7
|
-
| "approval";
|
|
8
|
-
export type NotificationChannel = "in-app" | "email" | "sms";
|
|
9
|
-
export type NotificationStatus = "pending" | "sent" | "read" | "failed";
|
|
10
|
-
|
|
11
|
-
export interface Notification {
|
|
12
|
-
id: string;
|
|
13
|
-
userId: string;
|
|
14
|
-
title: string;
|
|
15
|
-
content: string;
|
|
16
|
-
type: NotificationType;
|
|
17
|
-
channel: NotificationChannel;
|
|
18
|
-
status: NotificationStatus;
|
|
19
|
-
/** Link to navigate when clicked */
|
|
20
|
-
link?: string;
|
|
21
|
-
/** Additional metadata */
|
|
22
|
-
metadata?: Record<string, unknown>;
|
|
23
|
-
createdAt: Date;
|
|
24
|
-
readAt?: Date;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface CreateNotificationInput {
|
|
28
|
-
userId: string;
|
|
29
|
-
title: string;
|
|
30
|
-
content: string;
|
|
31
|
-
type?: NotificationType;
|
|
32
|
-
channel?: NotificationChannel;
|
|
33
|
-
link?: string;
|
|
34
|
-
metadata?: Record<string, unknown>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface NotificationStorage {
|
|
38
|
-
save(notification: Notification): Promise<void>;
|
|
39
|
-
findByUserId(
|
|
40
|
-
userId: string,
|
|
41
|
-
options?: { unreadOnly?: boolean },
|
|
42
|
-
): Promise<Notification[]>;
|
|
43
|
-
findById(id: string): Promise<Notification | null>;
|
|
44
|
-
markAsRead(id: string): Promise<void>;
|
|
45
|
-
markAllAsRead(userId: string): Promise<void>;
|
|
46
|
-
delete(id: string): Promise<void>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface NotificationServiceOptions {
|
|
50
|
-
storage?: NotificationStorage;
|
|
51
|
-
}
|