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