Commit 8a15bc17 authored by ThinhNC's avatar ThinhNC

feat: add notification and reminder system

parent c7c389f4
......@@ -26,6 +26,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE.
- **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart, và context `period`/`currency`/`walletId` được chuẩn bị tại khối AI Financial Insights cho giai đoạn tích hợp tiếp theo.
- **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập; danh sách/count/reminder polling mỗi 30 giây khi đang hoạt động, refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
......@@ -57,6 +58,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module báo cáo tài chính đã có frontend tại `/reports`, kết nối đầy đủ Report API qua TanStack Query, gồm lọc ngày/tuần/tháng/năm/khoảng tùy chọn, ví và tiền tệ; KPI tổng quan, dòng tiền, cơ cấu chi tiêu, hiệu suất ngân sách, mục tiêu tiết kiệm, loading/error/empty state, responsive và bản dịch Việt/Anh. AI Assistant chưa được tích hợp; trang báo cáo hiện có khối preview/context làm nền tảng cho AI Financial Insights.
- Module thông báo và nhắc nhở đã có frontend tại `/notifications`, kết nối Notification/Reminder API qua TanStack Query, gồm badge chưa đọc toàn cục, danh sách/lọc/phân trang, đánh dấu đọc một/tất cả, xóa, điều hướng tới nguồn, CRUD và bật/tắt lịch nhắc, tùy chọn loại cảnh báo/kênh nhận; có polling gần thời gian thực, loading/error/empty state, xác nhận thao tác và bản dịch Việt/Anh.
## Khi cập nhật file này
......
......@@ -16,6 +16,7 @@ import { ThemeControl } from "@/components/shared/ThemeControl";
import { applyTheme, useThemeStore } from "@/stores/theme-store";
import { I18nProvider } from "@/i18n";
import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher";
import { NotificationShortcut } from "@/components/shared/NotificationShortcut";
import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide";
......@@ -34,6 +35,7 @@ import BudgetDetailPage from "@/pages/budgets/detail";
import SavingGoalsPage from "@/pages/saving-goals/index";
import SavingGoalDetailPage from "@/pages/saving-goals/detail";
import ReportsPage from "@/pages/reports/index";
import NotificationsPage from "@/pages/notifications/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -73,14 +75,13 @@ const Layout = () => {
<App theme={theme}>
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<div
className="finwise-header-controls fixed z-[1001] flex items-center gap-2"
>
<ThemeControl />
<LanguageSwitcher />
</div>
<ZMPRouter>
<DocumentTitle />
<div className="finwise-header-controls fixed z-[1001] flex items-center gap-2">
<NotificationShortcut />
<ThemeControl />
<LanguageSwitcher />
</div>
<AuthInitializer>
<AnimationRoutes>
{/* Public Auth Routes */}
......@@ -102,6 +103,7 @@ const Layout = () => {
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><NotificationsPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
......
......@@ -24,6 +24,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
......
import React from "react";
import { useNavigate } from "zmp-ui";
import { NotificationIcon } from "@/components/ui/icons";
import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { useI18n } from "@/i18n";
import { useAuthStore } from "@/stores/auth-store";
export const NotificationShortcut: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
const isInitialized = useAuthStore((state) => state.isInitialized);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const countQuery = useUnreadNotificationCount(isInitialized && isAuthenticated);
const count = countQuery.data?.data.count || 0;
if (!isInitialized || !isAuthenticated) return null;
return (
<button type="button" aria-label={count > 0 ? t("notification.badgeLabel", { count }) : t("notification.open")} title={t("notification.open")} onClick={() => navigate("/notifications")} className="relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35">
<NotificationIcon size={20} />
{count > 0 && <span className="absolute -right-1.5 -top-1.5 flex min-h-5 min-w-5 items-center justify-center rounded-full border-2 border-clay-bg bg-clay-expense px-1 font-nunito text-[10px] font-extrabold leading-none text-clay-on-primary">{count > 99 ? "99+" : count}</span>}
</button>
);
};
......@@ -276,9 +276,9 @@ body {
);
}
/* Reserve space for native right-buttons (96px), both 36px controls, and gaps. */
/* Reserve space for native right-buttons (96px), three 36px controls, and gaps. */
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
);
}
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { notificationService } from "@/services/notification.service";
import { NotificationQuery, UpdateNotificationSettingInput } from "@/types/notification";
export const notificationKeys = {
all: ["notifications"] as const,
lists: () => [...notificationKeys.all, "list"] as const,
list: (query: NotificationQuery) => [...notificationKeys.lists(), query] as const,
unread: () => [...notificationKeys.all, "unread-count"] as const,
setting: () => [...notificationKeys.all, "setting"] as const,
};
export function useNotifications(query: NotificationQuery, enabled = true) {
return useQuery({
queryKey: notificationKeys.list(query),
queryFn: () => notificationService.getNotifications(query),
enabled,
placeholderData: keepPreviousData,
staleTime: 15_000,
refetchInterval: enabled ? 30_000 : false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useUnreadNotificationCount(enabled = true) {
return useQuery({
queryKey: notificationKeys.unread(),
queryFn: notificationService.getUnreadCount,
enabled,
staleTime: 10_000,
refetchInterval: enabled ? 30_000 : false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
export function useNotificationSetting(enabled = true) {
return useQuery({
queryKey: notificationKeys.setting(),
queryFn: notificationService.getSetting,
enabled,
staleTime: 60_000,
refetchOnWindowFocus: true,
});
}
function useNotificationAction(action: (id: string) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
});
}
export function useMarkNotificationRead() {
return useNotificationAction(notificationService.markRead);
}
export function useDeleteNotification() {
return useNotificationAction(notificationService.remove);
}
export function useMarkAllNotificationsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: notificationService.markAllRead,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
});
}
export function useUpdateNotificationSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateNotificationSettingInput) => notificationService.updateSetting(input),
onSuccess: async (response) => {
queryClient.setQueryData(notificationKeys.setting(), response);
await queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
});
}
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { reminderService } from "@/services/reminder.service";
import { CreateReminderInput, ReminderQuery, UpdateReminderInput } from "@/types/reminder";
import { notificationKeys } from "./use-notifications";
export const reminderKeys = {
all: ["reminders"] as const,
lists: () => [...reminderKeys.all, "list"] as const,
list: (query: ReminderQuery) => [...reminderKeys.lists(), query] as const,
};
export function useReminders(query: ReminderQuery, enabled = true) {
return useQuery({
queryKey: reminderKeys.list(query),
queryFn: () => reminderService.getReminders(query),
enabled,
placeholderData: keepPreviousData,
staleTime: 20_000,
refetchInterval: enabled ? 30_000 : false,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
}
function useReminderMutation<TInput>(mutationFn: (input: TInput) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: reminderKeys.all }),
queryClient.invalidateQueries({ queryKey: notificationKeys.all }),
]);
},
});
}
export function useCreateReminder() {
return useReminderMutation((input: CreateReminderInput) => reminderService.create(input));
}
export function useUpdateReminder() {
return useReminderMutation(({ id, input }: { id: string; input: UpdateReminderInput }) => reminderService.update(id, input));
}
export function useDeleteReminder() {
return useReminderMutation((id: string) => reminderService.remove(id));
}
......@@ -51,7 +51,8 @@
"budgetDetail": "Budget Details",
"savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details",
"reports": "Financial Reports"
"reports": "Financial Reports",
"notifications": "Notifications & Reminders"
},
"validation": {
"emailRequired": "Email is required",
......@@ -199,7 +200,8 @@
"transfers": "Transfer between wallets",
"budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals",
"reports": "Financial reports & analytics"
"reports": "Financial reports & analytics",
"notifications": "Notifications & reminders"
},
"profile": {
"header": "Account",
......@@ -224,6 +226,8 @@
"passwordSubmit": "Change Password",
"passwordSuccess": "Password changed successfully! 🔑",
"passwordFailed": "Could not change password.",
"showPassword": "Show password",
"hidePassword": "Hide password",
"sessionManagement": "Manage sign-in sessions",
"sessionDescription": "Protect your account by signing out of all other devices and browsers you previously used.",
"logoutOthers": "Sign out other devices",
......@@ -1094,5 +1098,115 @@
"needTwoWallets": "You need at least two active wallets to make a transfer.",
"needTwoWalletsAction": "Create at least two active wallets before making a transfer. Tap to manage wallets.",
"paginationLabel": "Transfer history pagination"
},
"notification": {
"header": "Notifications & Reminders",
"open": "Open notification center",
"badgeLabel": "{{count}} unread notifications",
"overview": "Financial center",
"unreadCount": "unread notifications",
"unread": "Unread",
"tabs": { "inbox": "Notifications", "reminders": "Reminders", "settings": "Preferences" },
"list": "Recent notifications",
"syncing": "Updating...",
"loading": "Loading notifications...",
"loadFailed": "Could not load notifications",
"connectionFailed": "The notification service connection ran into a problem.",
"empty": "No notifications",
"emptyHint": "Budget, goal, transaction, and payment alerts will appear here.",
"paginationLabel": "Notification pagination",
"markRead": "Mark as read",
"markAllRead": "Mark all as read",
"markAllSuccess": "All notifications marked as read.",
"viewDetail": "View details",
"deleteTitle": "Delete notification?",
"deleteHint": "This notification will be permanently removed from your inbox.",
"deleteAria": "Delete notification {{title}}",
"deleteSuccess": "Notification deleted.",
"actionFailed": "Could not update the notification. Please try again.",
"filter": {
"all": "All", "unread": "Unread", "read": "Read",
"type": "Event type", "allTypes": "All types",
"priority": "Priority", "allPriorities": "All priorities"
},
"priority": { "LOW": "Low", "NORMAL": "Normal", "HIGH": "High", "CRITICAL": "Critical" },
"type": {
"BUDGET_NEAR_LIMIT": "Budget near limit",
"BUDGET_EXCEEDED": "Budget exceeded",
"SAVING_GOAL_NEAR_TARGET": "Goal near target",
"SAVING_GOAL_ACHIEVED": "Goal achieved",
"SAVING_GOAL_DUE_SOON": "Goal due soon",
"RECURRING_PAYMENT_DUE": "Payment due",
"UNUSUAL_TRANSACTION": "Unusual transaction",
"USER_REMINDER": "Personal reminder",
"SYSTEM": "System"
},
"content": {
"budgetNearTitle": "Budget nearing limit: {{name}}",
"budgetExceededTitle": "Budget exceeded: {{name}}",
"budgetMessage": "Spending has reached {{percentage}}% of this budget.",
"goalNearTitle": "Saving goal almost reached: {{name}}",
"goalNearMessage": "You have completed {{percentage}}% of this saving goal.",
"goalAchievedTitle": "Saving goal achieved: {{name}}",
"goalAchievedMessage": "Congratulations! You have reached this saving goal.",
"goalDueTitle": "Saving goal deadline approaching: {{name}}",
"goalDueMessage": "The target date is {{date}}.",
"unusualTitle": "Unusual transaction detected",
"unusualMessage": "This expense is significantly higher than your recent spending in the same wallet.",
"reminderFallback": "A scheduled financial reminder is due."
},
"settings": {
"alertTitle": "Alert types",
"alertHint": "Choose which financial signals FinWise should monitor.",
"budget": "Budget alerts", "budgetHint": "Spending approaching or exceeding a limit.",
"savingGoal": "Saving goal progress", "savingGoalHint": "Goal progress and deadlines.",
"reminder": "Payments & reminders", "reminderHint": "Personal schedules and recurring expenses.",
"unusual": "Unusual transactions", "unusualHint": "Expenses significantly above recent history.",
"channelTitle": "Delivery channels", "channelHint": "Select at least one channel for alerts.",
"channelRequired": "At least one channel is required. Zalo and Push are ready for a future provider connection.",
"save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences."
},
"channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" },
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Connection coming soon", "PUSH": "Connection coming soon" }
},
"reminder": {
"createShort": "New reminder",
"createTitle": "Create reminder",
"editTitle": "Edit reminder",
"list": "Your reminders",
"loading": "Loading reminders...",
"loadFailed": "Could not load reminders",
"connectionFailed": "The reminder service connection ran into a problem.",
"empty": "No reminders yet",
"emptyHint": "Schedule a bill, rent payment, or another important financial task.",
"createFirst": "Create your first reminder",
"syncing": "Updating...",
"paginationLabel": "Reminder pagination",
"active": "Active", "paused": "Paused", "pause": "Pause", "resume": "Resume",
"schedule": "Schedule", "nextTrigger": "Next reminder", "noNextTrigger": "No upcoming trigger",
"createSuccess": "Reminder created.", "updateSuccess": "Reminder updated.",
"pauseSuccess": "Reminder paused.", "resumeSuccess": "Reminder resumed.",
"saveFailed": "Could not save the reminder. Check the schedule and try again.",
"deleteTitle": "Delete reminder?", "deleteHint": "This reminder will be permanently deleted.",
"deleteSuccess": "Reminder deleted.", "deleteFailed": "Could not delete the reminder.",
"type": { "GENERAL": "General reminder", "RECURRING_PAYMENT": "Recurring payment" },
"frequency": {
"ONCE": "Once", "DAILY": "Every {{count}} day(s)", "WEEKLY": "Every {{count}} week(s)",
"MONTHLY": "Every {{count}} month(s)", "YEARLY": "Every {{count}} year(s)"
},
"frequencyOption": { "ONCE": "Once", "DAILY": "Daily", "WEEKLY": "Weekly", "MONTHLY": "Monthly", "YEARLY": "Yearly" },
"filter": { "type": "Reminder type", "allTypes": "All types", "status": "Status", "allStatuses": "All statuses" },
"form": {
"type": "Reminder type", "title": "Title", "titlePlaceholder": "For example: Pay electricity bill",
"message": "Note", "messagePlaceholder": "Amount, provider, or anything else to remember...",
"remindAt": "Start time", "frequency": "Frequency", "repeatInterval": "Repeat every",
"endAt": "Repeat until (optional)", "active": "Enable reminder", "activeHint": "A notification is created when the schedule is due."
},
"validation": {
"titleRequired": "Please enter a title", "titleMax": "Title must not exceed 160 characters",
"messageMax": "Note must not exceed 2000 characters", "remindAtRequired": "Please select a reminder time",
"interval": "Repeat interval must be from 1 to 365", "future": "A one-time reminder must be in the future",
"onceNoEnd": "A one-time reminder cannot have an end time", "endAfter": "End time must be after the start time"
}
}
}
......@@ -51,7 +51,8 @@
"budgetDetail": "Chi tiết ngân sách",
"savingGoals": "Mục tiêu tiết kiệm",
"savingGoalDetail": "Chi tiết mục tiêu tiết kiệm",
"reports": "Báo cáo tài chính"
"reports": "Báo cáo tài chính",
"notifications": "Thông báo & nhắc nhở"
},
"validation": {
"emailRequired": "Email không được để trống",
......@@ -199,7 +200,8 @@
"transfers": "Chuyển tiền giữa các ví",
"budgets": "Quản lý ngân sách chi tiêu",
"savingGoals": "Quản lý mục tiêu tiết kiệm",
"reports": "Báo cáo & phân tích tài chính"
"reports": "Báo cáo & phân tích tài chính",
"notifications": "Thông báo & nhắc nhở"
},
"profile": {
"header": "Tài Khoản",
......@@ -224,6 +226,8 @@
"passwordSubmit": "Đổi Mật Khẩu",
"passwordSuccess": "Thay đổi mật khẩu thành công! 🔑",
"passwordFailed": "Thay đổi mật khẩu thất bại.",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"sessionManagement": "Quản lý phiên đăng nhập",
"sessionDescription": "Bảo vệ tài khoản bằng cách đăng xuất khỏi tất cả các thiết bị và trình duyệt khác mà bạn đã đăng nhập trước đó.",
"logoutOthers": "Đăng xuất các thiết bị khác",
......@@ -1103,5 +1107,115 @@
"needTwoWallets": "Bạn cần ít nhất hai ví đang hoạt động để chuyển tiền.",
"needTwoWalletsAction": "Tạo ít nhất hai ví đang hoạt động trước khi chuyển tiền. Chạm để quản lý ví.",
"paginationLabel": "Phân trang lịch sử chuyển tiền"
},
"notification": {
"header": "Thông báo & nhắc nhở",
"open": "Mở trung tâm thông báo",
"badgeLabel": "Có {{count}} thông báo chưa đọc",
"overview": "Trung tâm tài chính",
"unreadCount": "thông báo chưa đọc",
"unread": "Chưa đọc",
"tabs": { "inbox": "Thông báo", "reminders": "Nhắc nhở", "settings": "Tùy chọn" },
"list": "Thông báo gần đây",
"syncing": "Đang cập nhật...",
"loading": "Đang tải thông báo...",
"loadFailed": "Không thể tải thông báo",
"connectionFailed": "Kết nối tới dịch vụ thông báo đang gặp sự cố.",
"empty": "Không có thông báo nào",
"emptyHint": "Các cảnh báo ngân sách, mục tiêu, giao dịch và thanh toán sẽ xuất hiện tại đây.",
"paginationLabel": "Phân trang thông báo",
"markRead": "Đánh dấu đã đọc",
"markAllRead": "Đánh dấu tất cả đã đọc",
"markAllSuccess": "Đã đánh dấu tất cả thông báo là đã đọc.",
"viewDetail": "Xem chi tiết",
"deleteTitle": "Xóa thông báo?",
"deleteHint": "Thông báo sẽ bị xóa vĩnh viễn khỏi hộp thư của bạn.",
"deleteAria": "Xóa thông báo {{title}}",
"deleteSuccess": "Đã xóa thông báo.",
"actionFailed": "Không thể cập nhật thông báo. Vui lòng thử lại.",
"filter": {
"all": "Tất cả", "unread": "Chưa đọc", "read": "Đã đọc",
"type": "Loại sự kiện", "allTypes": "Tất cả loại",
"priority": "Mức ưu tiên", "allPriorities": "Tất cả mức"
},
"priority": { "LOW": "Thấp", "NORMAL": "Bình thường", "HIGH": "Quan trọng", "CRITICAL": "Khẩn cấp" },
"type": {
"BUDGET_NEAR_LIMIT": "Sắp vượt ngân sách",
"BUDGET_EXCEEDED": "Vượt ngân sách",
"SAVING_GOAL_NEAR_TARGET": "Sắp đạt mục tiêu",
"SAVING_GOAL_ACHIEVED": "Đã đạt mục tiêu",
"SAVING_GOAL_DUE_SOON": "Mục tiêu sắp đến hạn",
"RECURRING_PAYMENT_DUE": "Thanh toán đến hạn",
"UNUSUAL_TRANSACTION": "Giao dịch bất thường",
"USER_REMINDER": "Nhắc nhở cá nhân",
"SYSTEM": "Hệ thống"
},
"content": {
"budgetNearTitle": "Ngân sách sắp chạm hạn mức: {{name}}",
"budgetExceededTitle": "Ngân sách đã vượt hạn mức: {{name}}",
"budgetMessage": "Chi tiêu đã đạt {{percentage}}% ngân sách này.",
"goalNearTitle": "Sắp đạt mục tiêu tiết kiệm: {{name}}",
"goalNearMessage": "Bạn đã hoàn thành {{percentage}}% mục tiêu tiết kiệm này.",
"goalAchievedTitle": "Đã đạt mục tiêu tiết kiệm: {{name}}",
"goalAchievedMessage": "Chúc mừng! Bạn đã hoàn thành mục tiêu tiết kiệm này.",
"goalDueTitle": "Mục tiêu tiết kiệm sắp đến hạn: {{name}}",
"goalDueMessage": "Ngày mục tiêu là {{date}}.",
"unusualTitle": "Phát hiện giao dịch bất thường",
"unusualMessage": "Khoản chi này cao hơn đáng kể so với lịch sử chi tiêu gần đây trong cùng ví.",
"reminderFallback": "Một lịch nhắc tài chính đã đến hạn."
},
"settings": {
"alertTitle": "Loại cảnh báo",
"alertHint": "Chọn những tín hiệu tài chính bạn muốn FinWise theo dõi.",
"budget": "Cảnh báo ngân sách", "budgetHint": "Sắp chạm hoặc vượt hạn mức chi tiêu.",
"savingGoal": "Tiến độ tiết kiệm", "savingGoalHint": "Tiến độ và thời hạn của mục tiêu.",
"reminder": "Khoản thanh toán & nhắc nhở", "reminderHint": "Lịch nhắc cá nhân và khoản chi định kỳ.",
"unusual": "Giao dịch bất thường", "unusualHint": "Khoản chi lớn bất thường so với lịch sử.",
"channelTitle": "Kênh nhận thông báo", "channelHint": "Chọn ít nhất một kênh nhận cảnh báo.",
"channelRequired": "Luôn cần ít nhất một kênh. Zalo và Push đã được chuẩn bị cho giai đoạn kết nối nhà cung cấp.",
"save": "Lưu tùy chọn", "saveSuccess": "Đã lưu tùy chọn thông báo.", "saveFailed": "Không thể lưu tùy chọn thông báo."
},
"channel": { "IN_APP": "Trong ứng dụng", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" },
"channelHint": { "IN_APP": "Hộp thư và badge", "EMAIL": "Gửi tới email tài khoản", "ZALO": "Kết nối sắp tới", "PUSH": "Kết nối sắp tới" }
},
"reminder": {
"createShort": "Tạo lịch",
"createTitle": "Tạo nhắc nhở",
"editTitle": "Chỉnh sửa nhắc nhở",
"list": "Lịch nhắc của bạn",
"loading": "Đang tải lịch nhắc...",
"loadFailed": "Không thể tải lịch nhắc",
"connectionFailed": "Kết nối tới dịch vụ nhắc nhở đang gặp sự cố.",
"empty": "Chưa có lịch nhắc nào",
"emptyHint": "Tạo lịch cho hóa đơn, tiền thuê nhà hoặc một việc tài chính quan trọng.",
"createFirst": "Tạo nhắc nhở đầu tiên",
"syncing": "Đang cập nhật...",
"paginationLabel": "Phân trang nhắc nhở",
"active": "Đang bật", "paused": "Tạm dừng", "pause": "Tạm dừng", "resume": "Bật lại",
"schedule": "Chu kỳ", "nextTrigger": "Lần nhắc tiếp theo", "noNextTrigger": "Chưa có lịch tiếp theo",
"createSuccess": "Đã tạo nhắc nhở.", "updateSuccess": "Đã cập nhật nhắc nhở.",
"pauseSuccess": "Đã tạm dừng nhắc nhở.", "resumeSuccess": "Đã bật lại nhắc nhở.",
"saveFailed": "Không thể lưu nhắc nhở. Hãy kiểm tra lịch và thử lại.",
"deleteTitle": "Xóa nhắc nhở?", "deleteHint": "Lịch nhắc này sẽ bị xóa vĩnh viễn.",
"deleteSuccess": "Đã xóa nhắc nhở.", "deleteFailed": "Không thể xóa nhắc nhở.",
"type": { "GENERAL": "Nhắc nhở chung", "RECURRING_PAYMENT": "Thanh toán định kỳ" },
"frequency": {
"ONCE": "Một lần", "DAILY": "Mỗi {{count}} ngày", "WEEKLY": "Mỗi {{count}} tuần",
"MONTHLY": "Mỗi {{count}} tháng", "YEARLY": "Mỗi {{count}} năm"
},
"frequencyOption": { "ONCE": "Một lần", "DAILY": "Hàng ngày", "WEEKLY": "Hàng tuần", "MONTHLY": "Hàng tháng", "YEARLY": "Hàng năm" },
"filter": { "type": "Loại nhắc nhở", "allTypes": "Tất cả loại", "status": "Trạng thái", "allStatuses": "Tất cả trạng thái" },
"form": {
"type": "Loại nhắc nhở", "title": "Tiêu đề", "titlePlaceholder": "Ví dụ: Thanh toán tiền điện",
"message": "Ghi chú", "messagePlaceholder": "Số tiền, nhà cung cấp hoặc thông tin cần nhớ...",
"remindAt": "Thời gian bắt đầu", "frequency": "Tần suất", "repeatInterval": "Lặp lại sau mỗi",
"endAt": "Kết thúc lặp (không bắt buộc)", "active": "Bật nhắc nhở", "activeHint": "Thông báo sẽ được tạo khi lịch đến hạn."
},
"validation": {
"titleRequired": "Vui lòng nhập tiêu đề", "titleMax": "Tiêu đề tối đa 160 ký tự",
"messageMax": "Ghi chú tối đa 2000 ký tự", "remindAtRequired": "Vui lòng chọn thời gian nhắc",
"interval": "Chu kỳ lặp phải từ 1 đến 365", "future": "Lịch nhắc một lần phải nằm trong tương lai",
"onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc", "endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu"
}
}
}
export interface FinWiseNavigationState {
fromNotifications?: boolean;
}
export function isFromNotifications(state: unknown): boolean {
if (!state || typeof state !== "object") return false;
return (state as FinWiseNavigationState).fromNotifications === true;
}
import { TranslationFunction } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { NotificationItem, NotificationType } from "@/types/notification";
interface NotificationFormatter {
t: TranslationFunction;
intlLocale: string;
formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
}
interface NotificationContent {
title: string;
message: string;
}
const generatedTitlePrefixes: Partial<Record<NotificationType, string>> = {
BUDGET_NEAR_LIMIT: "Budget nearing limit: ",
BUDGET_EXCEEDED: "Budget exceeded: ",
SAVING_GOAL_NEAR_TARGET: "Saving goal almost reached: ",
SAVING_GOAL_ACHIEVED: "Saving goal achieved: ",
SAVING_GOAL_DUE_SOON: "Saving goal deadline approaching: ",
};
function getTextData(notification: NotificationItem, key: string): string | undefined {
const value = notification.data?.[key];
return typeof value === "string" || typeof value === "number" ? String(value) : undefined;
}
function formatPercentage(notification: NotificationItem, formatter: NotificationFormatter): string {
const rawValue = getTextData(notification, "usagePercentage")
?? getTextData(notification, "progressPercentage");
const value = rawValue === undefined ? Number.NaN : Number(rawValue);
return Number.isFinite(value)
? formatter.formatNumber(value, { maximumFractionDigits: 1 })
: "—";
}
function extractGeneratedSourceName(notification: NotificationItem): string | undefined {
const prefix = generatedTitlePrefixes[notification.type];
if (!prefix || !notification.title.startsWith(prefix)) return undefined;
const name = notification.title.slice(prefix.length).trim();
return name || undefined;
}
function extractBusinessDate(message: string): string | undefined {
return message.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0];
}
export function formatNotificationContent(
notification: NotificationItem,
formatter: NotificationFormatter,
): NotificationContent {
const name = extractGeneratedSourceName(notification);
const percentage = formatPercentage(notification, formatter);
if (notification.type === "BUDGET_NEAR_LIMIT" && name) {
return {
title: formatter.t("notification.content.budgetNearTitle", { name }),
message: formatter.t("notification.content.budgetMessage", { percentage }),
};
}
if (notification.type === "BUDGET_EXCEEDED" && name) {
return {
title: formatter.t("notification.content.budgetExceededTitle", { name }),
message: formatter.t("notification.content.budgetMessage", { percentage }),
};
}
if (notification.type === "SAVING_GOAL_NEAR_TARGET" && name) {
return {
title: formatter.t("notification.content.goalNearTitle", { name }),
message: formatter.t("notification.content.goalNearMessage", { percentage }),
};
}
if (notification.type === "SAVING_GOAL_ACHIEVED" && name) {
return {
title: formatter.t("notification.content.goalAchievedTitle", { name }),
message: formatter.t("notification.content.goalAchievedMessage"),
};
}
if (notification.type === "SAVING_GOAL_DUE_SOON" && name) {
const businessDate = extractBusinessDate(notification.message);
if (businessDate) {
const date = formatBusinessDate(businessDate, formatter.intlLocale, {
year: "numeric",
month: "short",
day: "numeric",
});
return {
title: formatter.t("notification.content.goalDueTitle", { name }),
message: formatter.t("notification.content.goalDueMessage", { date }),
};
}
}
if (
notification.type === "UNUSUAL_TRANSACTION"
&& notification.title === "Unusual transaction detected"
) {
return {
title: formatter.t("notification.content.unusualTitle"),
message: formatter.t("notification.content.unusualMessage"),
};
}
if (
(notification.type === "USER_REMINDER" || notification.type === "RECURRING_PAYMENT_DUE")
&& notification.message === "A scheduled reminder is due."
) {
return {
title: notification.title,
message: formatter.t("notification.content.reminderFallback"),
};
}
// User-authored reminders, legacy notifications, and future AI content stay verbatim.
return { title: notification.title, message: notification.message };
}
import React, { useState } from "react";
import { Header, Page, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { Header, Page, useLocation, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
......@@ -16,6 +16,7 @@ import {
import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message";
import { isFromNotifications } from "@/lib/navigation-state";
import { BudgetUsageStatus, CreateBudgetInput } from "@/types/budget";
import { BudgetFormModal } from "./components/BudgetFormModal";
import { BudgetSkeleton } from "./components/BudgetSkeleton";
......@@ -30,6 +31,7 @@ const BudgetDetailPage: React.FC = () => {
const params = useParams<{ id: string }>();
const budgetId = params.id || "";
const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar();
const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const [isEditOpen, setIsEditOpen] = useState(false);
......@@ -69,7 +71,7 @@ const BudgetDetailPage: React.FC = () => {
return (
<Page className="page">
<Header title={t("budget.detailHeader")} showBackIcon onBackClick={() => navigate("/budgets")} />
<Header title={t("budget.detailHeader")} showBackIcon onBackClick={() => isFromNotifications(location.state) ? navigate(-1) : navigate("/budgets")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
......@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients, WalletIcon } from "@/components/ui/icons";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
function HomePage() {
......@@ -102,6 +102,13 @@ function HomePage() {
>
{t("home.reports")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/notifications")}
>
{t("home.notifications")}
</Button>
<Button
variant="secondary"
fullWidth
......
import React from "react";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { NotificationIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { formatNotificationContent } from "@/lib/notification-format";
import { NotificationItem, NotificationPriority, NotificationType } from "@/types/notification";
interface NotificationCardProps {
notification: NotificationItem;
isProcessing: boolean;
onOpen: () => void;
onMarkRead: () => void;
onDelete: () => void;
}
const priorityBadge: Record<NotificationPriority, BadgeType> = {
LOW: "info",
NORMAL: "primary",
HIGH: "warning",
CRITICAL: "expense",
};
const typeSurface: Record<NotificationType, string> = {
BUDGET_NEAR_LIMIT: "bg-clay-warning/20",
BUDGET_EXCEEDED: "bg-clay-expense/20",
SAVING_GOAL_NEAR_TARGET: "bg-clay-income/20",
SAVING_GOAL_ACHIEVED: "bg-clay-income/25",
SAVING_GOAL_DUE_SOON: "bg-clay-warning/20",
RECURRING_PAYMENT_DUE: "bg-clay-info/20",
UNUSUAL_TRANSACTION: "bg-clay-expense/20",
USER_REMINDER: "bg-clay-primary/20",
SYSTEM: "bg-clay-info/20",
};
export const NotificationCard: React.FC<NotificationCardProps> = ({
notification,
isProcessing,
onOpen,
onMarkRead,
onDelete,
}) => {
const { t, intlLocale, formatDate, formatNumber } = useI18n();
const content = formatNotificationContent(notification, { t, intlLocale, formatNumber });
return <article
className={`relative rounded-clay-lg border p-4 shadow-clay-raised transition-all duration-200 ease-in-out ${notification.readAt ? "border-clay-highlight/40 bg-clay-surface" : "border-clay-primary/35 bg-clay-surface"}`}
>
{!notification.readAt && (
<span className="absolute right-4 top-4 h-3 w-3 rounded-full bg-clay-primary shadow-clay-raised" aria-label={t("notification.unread")} />
)}
<div className="flex items-start gap-3 pr-5">
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-clay-sm shadow-clay-pressed ${typeSurface[notification.type]}`}>
<NotificationIcon size={24} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge type={priorityBadge[notification.priority]}>{t(`notification.priority.${notification.priority}`)}</Badge>
<span className="clay-caption">{t(`notification.type.${notification.type}`)}</span>
</div>
<h3 className="mt-2 font-baloo text-lg font-bold leading-tight text-clay-text">{content.title}</h3>
<p className="mt-1 whitespace-pre-wrap font-nunito text-sm leading-relaxed text-clay-text-muted">{content.message}</p>
<time className="clay-caption mt-2 block" dateTime={notification.createdAt}>
{formatDate(notification.createdAt, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</time>
</div>
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2 border-t border-clay-text-muted/10 pt-3">
{!notification.readAt && (
<button type="button" disabled={isProcessing} onClick={onMarkRead} className="rounded-full px-3 py-1.5 font-nunito text-xs font-bold text-clay-primary transition-all duration-200 ease-in-out hover:bg-clay-primary/10 disabled:opacity-50">
{t("notification.markRead")}
</button>
)}
{notification.actionUrl && (
<button type="button" disabled={isProcessing} onClick={onOpen} className="rounded-full bg-clay-primary px-3 py-1.5 font-nunito text-xs font-bold text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed disabled:opacity-50">
{t("notification.viewDetail")}
</button>
)}
<button type="button" disabled={isProcessing} onClick={onDelete} className="rounded-full px-3 py-1.5 font-nunito text-xs font-bold text-clay-expense transition-all duration-200 ease-in-out hover:bg-clay-expense/10 disabled:opacity-50" aria-label={t("notification.deleteAria", { title: notification.title })}>
{t("common.delete")}
</button>
</div>
</article>;
};
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { NotificationChannel, NotificationSetting } from "@/types/notification";
import { useI18n } from "@/i18n";
interface NotificationSettingsProps {
setting: NotificationSetting;
isSaving: boolean;
onSave: (setting: NotificationSetting) => void;
}
const channelOptions: NotificationChannel[] = ["IN_APP", "EMAIL", "ZALO", "PUSH"];
export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ setting, isSaving, onSave }) => {
const { t } = useI18n();
const [draft, setDraft] = useState(setting);
useEffect(() => setDraft(setting), [setting]);
const toggleChannel = (channel: NotificationChannel) => {
setDraft((current) => {
const selected = current.channels.includes(channel);
if (selected && current.channels.length === 1) return current;
return { ...current, channels: selected ? current.channels.filter((item) => item !== channel) : [...current.channels, channel] };
});
};
const toggles: Array<{ key: keyof Omit<NotificationSetting, "channels">; title: string; hint: string }> = [
{ key: "budgetAlertsEnabled", title: t("notification.settings.budget"), hint: t("notification.settings.budgetHint") },
{ key: "savingGoalAlertsEnabled", title: t("notification.settings.savingGoal"), hint: t("notification.settings.savingGoalHint") },
{ key: "reminderAlertsEnabled", title: t("notification.settings.reminder"), hint: t("notification.settings.reminderHint") },
{ key: "unusualTxnAlertsEnabled", title: t("notification.settings.unusual"), hint: t("notification.settings.unusualHint") },
];
return (
<div className="flex flex-col gap-4">
<Card className="p-5">
<h2 className="clay-title-h3">{t("notification.settings.alertTitle")}</h2>
<p className="clay-caption mt-1">{t("notification.settings.alertHint")}</p>
<div className="mt-4 flex flex-col gap-3">
{toggles.map((item) => (
<div key={item.key} className="flex items-center justify-between gap-4 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<div><p className="font-nunito text-sm font-bold text-clay-text">{item.title}</p><p className="clay-caption mt-0.5">{item.hint}</p></div>
<button type="button" role="switch" aria-checked={draft[item.key]} aria-label={item.title} disabled={isSaving} onClick={() => setDraft((current) => ({ ...current, [item.key]: !current[item.key] }))} className={`relative h-7 w-12 shrink-0 rounded-full border transition-all duration-200 ease-in-out disabled:opacity-50 ${draft[item.key] ? "border-clay-primary-dark/30 bg-clay-primary" : "border-clay-border bg-clay-surface shadow-clay-pressed"}`}><span className={`absolute top-0.5 h-5 w-5 rounded-full bg-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out ${draft[item.key] ? "left-6" : "left-1"}`} /></button>
</div>
))}
</div>
</Card>
<Card className="p-5">
<h2 className="clay-title-h3">{t("notification.settings.channelTitle")}</h2>
<p className="clay-caption mt-1">{t("notification.settings.channelHint")}</p>
<div className="mt-4 grid grid-cols-2 gap-3">
{channelOptions.map((channel) => {
const selected = draft.channels.includes(channel);
return <button type="button" key={channel} aria-pressed={selected} disabled={isSaving} onClick={() => toggleChannel(channel)} className={`rounded-clay-sm border p-3 text-left transition-all duration-200 ease-in-out disabled:opacity-50 ${selected ? "border-clay-primary/50 bg-clay-primary/15 shadow-clay-raised" : "border-clay-highlight/40 bg-clay-bg shadow-clay-pressed"}`}><span className="block font-nunito text-sm font-bold text-clay-text">{t(`notification.channel.${channel}`)}</span><span className="clay-caption mt-1 block">{t(`notification.channelHint.${channel}`)}</span></button>;
})}
</div>
<p className="clay-caption mt-3">{t("notification.settings.channelRequired")}</p>
</Card>
<Button fullWidth disabled={isSaving} onClick={() => onSave(draft)}>{isSaving ? t("common.saving") : t("notification.settings.save")}</Button>
</div>
);
};
import React from "react";
export const NotificationSkeleton: React.FC = () => (
<div className="animate-pulse rounded-clay-lg border border-clay-highlight/30 bg-clay-surface p-4 shadow-clay-raised" aria-hidden="true">
<div className="flex gap-3"><div className="h-11 w-11 rounded-clay-sm bg-clay-bg shadow-clay-pressed" /><div className="flex-1 space-y-3"><div className="h-5 w-24 rounded-full bg-clay-bg shadow-clay-pressed" /><div className="h-5 w-3/4 rounded-full bg-clay-bg shadow-clay-pressed" /><div className="h-4 w-full rounded-full bg-clay-bg shadow-clay-pressed" /></div></div>
</div>
);
import React from "react";
import { Badge } from "@/components/ui/Badge";
import { TranslationFunction } from "@/i18n";
import { Reminder } from "@/types/reminder";
interface ReminderCardProps {
reminder: Reminder;
formatDate: (value: string | number | Date, options?: Intl.DateTimeFormatOptions) => string;
t: TranslationFunction;
isProcessing: boolean;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
}
export const ReminderCard: React.FC<ReminderCardProps> = ({ reminder, formatDate, t, isProcessing, onEdit, onToggle, onDelete }) => (
<article className="rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-4 shadow-clay-raised transition-all duration-200 ease-in-out">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap gap-2">
<Badge type={reminder.type === "RECURRING_PAYMENT" ? "warning" : "primary"}>{t(`reminder.type.${reminder.type}`)}</Badge>
<Badge type={reminder.isActive ? "income" : "info"}>{t(reminder.isActive ? "reminder.active" : "reminder.paused")}</Badge>
</div>
<h3 className="mt-2 font-baloo text-lg font-bold text-clay-text">{reminder.title}</h3>
{reminder.message && <p className="mt-1 whitespace-pre-wrap font-nunito text-sm text-clay-text-muted">{reminder.message}</p>}
</div>
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-clay-sm shadow-clay-pressed ${reminder.type === "RECURRING_PAYMENT" ? "bg-clay-warning/20" : "bg-clay-primary/20"}`} aria-hidden="true">
<span className="font-baloo text-xl font-bold text-clay-text">{reminder.type === "RECURRING_PAYMENT" ? "$" : "⏰"}</span>
</div>
</div>
<dl className="mt-4 grid grid-cols-1 gap-2 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed sm:grid-cols-2">
<div><dt className="clay-caption">{t("reminder.schedule")}</dt><dd className="font-nunito text-sm font-bold text-clay-text">{t(`reminder.frequency.${reminder.frequency}`, { count: reminder.repeatInterval })}</dd></div>
<div><dt className="clay-caption">{t("reminder.nextTrigger")}</dt><dd className="font-nunito text-sm font-bold text-clay-text">{reminder.nextTriggerAt ? formatDate(reminder.nextTriggerAt, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : t("reminder.noNextTrigger")}</dd></div>
</dl>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<button type="button" disabled={isProcessing} onClick={onToggle} className="rounded-full px-3 py-1.5 font-nunito text-xs font-bold text-clay-primary transition-all duration-200 ease-in-out hover:bg-clay-primary/10 disabled:opacity-50">{t(reminder.isActive ? "reminder.pause" : "reminder.resume")}</button>
<button type="button" disabled={isProcessing} onClick={onEdit} className="rounded-full px-3 py-1.5 font-nunito text-xs font-bold text-clay-text transition-all duration-200 ease-in-out hover:bg-clay-bg disabled:opacity-50">{t("common.edit")}</button>
<button type="button" disabled={isProcessing} onClick={onDelete} className="rounded-full px-3 py-1.5 font-nunito text-xs font-bold text-clay-expense transition-all duration-200 ease-in-out hover:bg-clay-expense/10 disabled:opacity-50">{t("common.delete")}</button>
</div>
</article>
);
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { TranslationFunction, useI18n } from "@/i18n";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
import { CreateReminderInput, Reminder, ReminderFrequency, ReminderType } from "@/types/reminder";
const createSchema = (t: TranslationFunction) => z.object({
type: z.enum(["GENERAL", "RECURRING_PAYMENT"]),
title: z.string().trim().min(1, t("reminder.validation.titleRequired")).max(160, t("reminder.validation.titleMax")),
message: z.string().max(2000, t("reminder.validation.messageMax")),
remindAt: z.string().min(1, t("reminder.validation.remindAtRequired")),
frequency: z.enum(["ONCE", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"]),
repeatInterval: z.number().int().min(1, t("reminder.validation.interval")).max(365, t("reminder.validation.interval")),
endAt: z.string(),
isActive: z.boolean(),
}).superRefine((values, context) => {
if (!values.remindAt) return;
const remindAt = new Date(businessWallTimeToIso(values.remindAt));
if (values.frequency === "ONCE" && remindAt.getTime() <= Date.now()) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["remindAt"], message: t("reminder.validation.future") });
}
if (values.frequency === "ONCE" && values.endAt) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["endAt"], message: t("reminder.validation.onceNoEnd") });
}
if (values.endAt && new Date(businessWallTimeToIso(values.endAt)) <= remindAt) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["endAt"], message: t("reminder.validation.endAfter") });
}
});
type FormValues = z.infer<ReturnType<typeof createSchema>>;
interface ReminderFormModalProps {
isOpen: boolean;
reminder?: Reminder;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: CreateReminderInput) => void;
}
function defaults(reminder?: Reminder): FormValues {
return {
type: reminder?.type || "GENERAL",
title: reminder?.title || "",
message: reminder?.message || "",
remindAt: reminder ? instantToBusinessDateTimeInput(reminder.remindAt) : instantToBusinessDateTimeInput(new Date(Date.now() + 60 * 60 * 1000)),
frequency: reminder?.frequency || "ONCE",
repeatInterval: reminder?.repeatInterval || 1,
endAt: reminder?.endAt ? instantToBusinessDateTimeInput(reminder.endAt) : "",
isActive: reminder?.isActive ?? true,
};
}
export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, reminder, isSubmitting, onClose, onSubmit }) => {
const { t } = useI18n();
const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo(() => defaults(reminder), [reminder]);
const formId = reminder ? `edit-reminder-${reminder.id}` : "create-reminder";
const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues });
const frequency = watch("frequency") as ReminderFrequency;
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
return (
<Modal isOpen={isOpen} onClose={onClose} title={reminder ? t("reminder.editTitle") : t("reminder.createTitle")} footer={<><Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button><Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>{isSubmitting ? t("common.saving") : t("common.save")}</Button></>}>
<form id={formId} className="flex flex-col gap-4" noValidate onSubmit={handleSubmit((values) => onSubmit({
type: values.type as ReminderType,
title: values.title.trim(),
message: values.message.trim() || null,
remindAt: businessWallTimeToIso(values.remindAt),
frequency: values.frequency as ReminderFrequency,
repeatInterval: values.frequency === "ONCE" ? 1 : values.repeatInterval,
endAt: values.frequency === "ONCE" || !values.endAt ? null : businessWallTimeToIso(values.endAt),
actionUrl: reminder?.actionUrl || null,
isActive: values.isActive,
}))}>
<Select label={t("reminder.form.type")} disabled={isSubmitting} options={[{ value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} error={errors.type?.message} {...register("type")} />
<Input label={t("reminder.form.title")} maxLength={160} disabled={isSubmitting} placeholder={t("reminder.form.titlePlaceholder")} error={errors.title?.message} {...register("title")} />
<div className="flex flex-col gap-2"><label htmlFor={`${formId}-message`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("reminder.form.message")}</label><textarea id={`${formId}-message`} rows={3} maxLength={2000} disabled={isSubmitting} placeholder={t("reminder.form.messagePlaceholder")} className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:opacity-60" {...register("message")} />{errors.message?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.message.message}</span>}</div>
<Input type="datetime-local" label={t("reminder.form.remindAt")} disabled={isSubmitting} error={errors.remindAt?.message} {...register("remindAt")} />
<Select label={t("reminder.form.frequency")} disabled={isSubmitting} options={["ONCE", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"].map((value) => ({ value, label: t(`reminder.frequencyOption.${value}`) }))} error={errors.frequency?.message} {...register("frequency")} />
{frequency !== "ONCE" && <><Input type="number" min={1} max={365} label={t("reminder.form.repeatInterval")} disabled={isSubmitting} error={errors.repeatInterval?.message} {...register("repeatInterval", { valueAsNumber: true })} /><Input type="datetime-local" label={t("reminder.form.endAt")} disabled={isSubmitting} error={errors.endAt?.message} {...register("endAt")} /></>}
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"><input type="checkbox" className="h-4 w-4 accent-clay-primary" disabled={isSubmitting} {...register("isActive")} /><span><span className="block font-nunito text-sm font-bold text-clay-text">{t("reminder.form.active")}</span><span className="clay-caption block">{t("reminder.form.activeHint")}</span></span></label>
</form>
</Modal>
);
};
import React, { useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs";
import { IconGradients, NotificationIcon, PlusIcon } from "@/components/ui/icons";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useNotificationSetting,
useUnreadNotificationCount,
useUpdateNotificationSetting,
} from "@/hooks/use-notifications";
import { useCreateReminder, useDeleteReminder, useReminders, useUpdateReminder } from "@/hooks/use-reminders";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { NotificationItem, NotificationPriority, NotificationQuery, NotificationType } from "@/types/notification";
import { CreateReminderInput, Reminder, ReminderQuery, ReminderType } from "@/types/reminder";
import { NotificationCard } from "./components/NotificationCard";
import { NotificationSettings } from "./components/NotificationSettings";
import { NotificationSkeleton } from "./components/NotificationSkeleton";
import { ReminderCard } from "./components/ReminderCard";
import { ReminderFormModal } from "./components/ReminderFormModal";
const PAGE_SIZE = 8;
type PageTab = "INBOX" | "REMINDERS" | "SETTINGS";
type ReadFilter = "ALL" | "UNREAD" | "READ";
type ActiveFilter = "ALL" | "ACTIVE" | "PAUSED";
type DeleteTarget = { kind: "notification"; item: NotificationItem } | { kind: "reminder"; item: Reminder } | null;
const NotificationsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t, formatDate } = useI18n();
const [tab, setTab] = useState<PageTab>("INBOX");
const [readFilter, setReadFilter] = useState<ReadFilter>("ALL");
const [typeFilter, setTypeFilter] = useState<"ALL" | NotificationType>("ALL");
const [priorityFilter, setPriorityFilter] = useState<"ALL" | NotificationPriority>("ALL");
const [notificationPage, setNotificationPage] = useState(1);
const [reminderType, setReminderType] = useState<"ALL" | ReminderType>("ALL");
const [activeFilter, setActiveFilter] = useState<ActiveFilter>("ALL");
const [reminderPage, setReminderPage] = useState(1);
const [isReminderFormOpen, setIsReminderFormOpen] = useState(false);
const [editingReminder, setEditingReminder] = useState<Reminder>();
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
const notificationQuery = useMemo<NotificationQuery>(() => ({
...(typeFilter !== "ALL" ? { type: typeFilter } : {}),
...(priorityFilter !== "ALL" ? { priority: priorityFilter } : {}),
...(readFilter !== "ALL" ? { isRead: readFilter === "READ" } : {}),
page: notificationPage,
limit: PAGE_SIZE,
}), [notificationPage, priorityFilter, readFilter, typeFilter]);
const reminderQuery = useMemo<ReminderQuery>(() => ({
...(reminderType !== "ALL" ? { type: reminderType } : {}),
...(activeFilter !== "ALL" ? { isActive: activeFilter === "ACTIVE" } : {}),
page: reminderPage,
limit: PAGE_SIZE,
}), [activeFilter, reminderPage, reminderType]);
const notificationsQuery = useNotifications(notificationQuery, tab === "INBOX");
const unreadQuery = useUnreadNotificationCount();
const settingQuery = useNotificationSetting(tab === "SETTINGS");
const remindersQuery = useReminders(reminderQuery, tab === "REMINDERS");
const markReadMutation = useMarkNotificationRead();
const markAllMutation = useMarkAllNotificationsRead();
const deleteNotificationMutation = useDeleteNotification();
const updateSettingMutation = useUpdateNotificationSetting();
const createReminderMutation = useCreateReminder();
const updateReminderMutation = useUpdateReminder();
const deleteReminderMutation = useDeleteReminder();
const notifications = notificationsQuery.data?.data || [];
const reminders = remindersQuery.data?.data || [];
const notificationPages = notificationsQuery.data?.meta.totalPages || 0;
const reminderPages = remindersQuery.data?.meta.totalPages || 0;
const unreadCount = unreadQuery.data?.data.count || 0;
const mutationPending = markReadMutation.isPending || deleteNotificationMutation.isPending || updateReminderMutation.isPending || deleteReminderMutation.isPending;
useEffect(() => setNotificationPage(1), [priorityFilter, readFilter, typeFilter]);
useEffect(() => setReminderPage(1), [activeFilter, reminderType]);
useEffect(() => { if (notificationPages > 0 && notificationPage > notificationPages) setNotificationPage(notificationPages); }, [notificationPage, notificationPages]);
useEffect(() => { if (reminderPages > 0 && reminderPage > reminderPages) setReminderPage(reminderPages); }, [reminderPage, reminderPages]);
const showError = (error: unknown, key: string) => openSnackbar({ type: "error", text: getErrorMessage(error, t(key)) });
const markRead = (id: string, after?: () => void) => markReadMutation.mutate(id, { onSuccess: after, onError: (error) => showError(error, "notification.actionFailed") });
const openNotification = (item: NotificationItem) => {
const go = () => {
if (!item.actionUrl) return;
if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer");
else navigate(
item.actionUrl.startsWith("/transactions/") ? "/transactions" : item.actionUrl,
{ state: { fromNotifications: true } },
);
};
if (item.readAt) go(); else markRead(item.id, go);
};
const handleReminderSubmit = (input: CreateReminderInput) => {
const close = () => { setIsReminderFormOpen(false); setEditingReminder(undefined); };
if (editingReminder) {
updateReminderMutation.mutate({ id: editingReminder.id, input }, { onSuccess: () => { close(); openSnackbar({ type: "success", text: t("reminder.updateSuccess") }); }, onError: (error) => showError(error, "reminder.saveFailed") });
} else {
createReminderMutation.mutate(input, { onSuccess: () => { close(); openSnackbar({ type: "success", text: t("reminder.createSuccess") }); }, onError: (error) => showError(error, "reminder.saveFailed") });
}
};
const confirmDelete = () => {
if (!deleteTarget) return;
if (deleteTarget.kind === "notification") {
deleteNotificationMutation.mutate(deleteTarget.item.id, { onSuccess: () => { setDeleteTarget(null); openSnackbar({ type: "success", text: t("notification.deleteSuccess") }); }, onError: (error) => showError(error, "notification.actionFailed") });
} else {
deleteReminderMutation.mutate(deleteTarget.item.id, { onSuccess: () => { setDeleteTarget(null); openSnackbar({ type: "success", text: t("reminder.deleteSuccess") }); }, onError: (error) => showError(error, "reminder.deleteFailed") });
}
};
const renderState = (kind: "notification" | "reminder", isLoading: boolean, isError: boolean, isEmpty: boolean, refetch: () => void) => {
if (isLoading) return <div className="flex flex-col gap-3" aria-label={t(`${kind}.loading`)}><NotificationSkeleton /><NotificationSkeleton /><NotificationSkeleton /></div>;
if (isError) return <Card className="flex flex-col items-center gap-3 py-9 text-center"><div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 font-baloo text-2xl font-bold text-clay-expense shadow-clay-pressed">!</div><div><h3 className="clay-title-h3">{t(`${kind}.loadFailed`)}</h3><p className="clay-caption mt-1">{t(`${kind}.connectionFailed`)}</p></div><Button variant="secondary" className="text-sm" onClick={refetch}>{t("common.retry")}</Button></Card>;
if (isEmpty) return <Card className="flex flex-col items-center gap-4 py-9 text-center"><div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-primary/15 shadow-clay-pressed"><NotificationIcon size={34} /></div><div><h3 className="clay-title-h3">{t(`${kind}.empty`)}</h3><p className="clay-caption mt-1 max-w-xs">{t(`${kind}.emptyHint`)}</p></div>{kind === "reminder" && <Button className="text-sm" onClick={() => setIsReminderFormOpen(true)}>{t("reminder.createFirst")}</Button>}</Card>;
return null;
};
return (
<Page className="page">
<Header title={t("notification.header")} showBackIcon onBackClick={() => navigate("/")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary"><div className="absolute -right-10 -top-10 h-36 w-36 rounded-full bg-clay-warning/25" /><div className="absolute -bottom-12 left-20 h-28 w-28 rounded-full bg-clay-on-primary/10" /><div className="relative flex items-start justify-between gap-3"><div><div className="mb-2 flex h-12 w-12 items-center justify-center rounded-clay bg-clay-warning shadow-clay-raised"><NotificationIcon size={29} /></div><p className="font-nunito text-sm font-bold text-clay-on-primary">{t("notification.overview")}</p><p className="font-baloo text-3xl font-bold text-clay-on-primary">{unreadQuery.isLoading ? "—" : unreadCount}</p><p className="font-nunito text-xs font-semibold text-clay-on-primary/80">{t("notification.unreadCount")}</p></div>{tab === "REMINDERS" && <Button variant="secondary" className="gap-1 px-4 py-2 text-sm" onClick={() => { setEditingReminder(undefined); setIsReminderFormOpen(true); }}><PlusIcon size={17} />{t("reminder.createShort")}</Button>}</div></Card>
<Tabs activeTab={tab} onChange={(key) => setTab(key as PageTab)} tabs={[{ key: "INBOX", label: t("notification.tabs.inbox") }, { key: "REMINDERS", label: t("notification.tabs.reminders") }, { key: "SETTINGS", label: t("notification.tabs.settings") }]} />
{tab === "INBOX" && <>
<Card className="flex flex-col gap-3 p-4"><Tabs activeTab={readFilter} onChange={(key) => setReadFilter(key as ReadFilter)} tabs={[{ key: "ALL", label: t("notification.filter.all") }, { key: "UNREAD", label: t("notification.filter.unread") }, { key: "READ", label: t("notification.filter.read") }]} /><div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><Select label={t("notification.filter.type")} value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as "ALL" | NotificationType)} options={[{ value: "ALL", label: t("notification.filter.allTypes") }, ...(["BUDGET_NEAR_LIMIT", "BUDGET_EXCEEDED", "SAVING_GOAL_NEAR_TARGET", "SAVING_GOAL_ACHIEVED", "SAVING_GOAL_DUE_SOON", "RECURRING_PAYMENT_DUE", "UNUSUAL_TRANSACTION", "USER_REMINDER", "SYSTEM"] as NotificationType[]).map((value) => ({ value, label: t(`notification.type.${value}`) }))]} /><Select label={t("notification.filter.priority")} value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | NotificationPriority)} options={[{ value: "ALL", label: t("notification.filter.allPriorities") }, ...(["LOW", "NORMAL", "HIGH", "CRITICAL"] as NotificationPriority[]).map((value) => ({ value, label: t(`notification.priority.${value}`) }))]} /></div>{unreadCount > 0 && <Button variant="ghost" className="self-end px-3 text-sm" disabled={markAllMutation.isPending} onClick={() => markAllMutation.mutate(undefined, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.markAllSuccess") }), onError: (error) => showError(error, "notification.actionFailed") })}>{markAllMutation.isPending ? t("common.processing") : t("notification.markAllRead")}</Button>}</Card>
<section className="flex flex-col gap-3" aria-labelledby="notification-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="notification-list-title" className="clay-title-h2">{t("notification.list")}</h2><p className="clay-caption">{t("common.results", { count: notificationsQuery.data?.meta.total || 0 })}</p></div>{notificationsQuery.isFetching && !notificationsQuery.isLoading && <span className="clay-caption animate-pulse">{t("notification.syncing")}</span>}</div>{renderState("notification", notificationsQuery.isLoading, notificationsQuery.isError, notifications.length === 0, () => { void notificationsQuery.refetch(); })}{notifications.map((item) => <NotificationCard key={item.id} notification={item} isProcessing={mutationPending} onOpen={() => openNotification(item)} onMarkRead={() => markRead(item.id)} onDelete={() => setDeleteTarget({ kind: "notification", item })} />)}{notificationPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("notification.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage <= 1 || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: notificationPage, total: notificationPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage >= notificationPages || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section>
</>}
{tab === "REMINDERS" && <><Card className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2"><Select label={t("reminder.filter.type")} value={reminderType} onChange={(event) => setReminderType(event.target.value as "ALL" | ReminderType)} options={[{ value: "ALL", label: t("reminder.filter.allTypes") }, { value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} /><Select label={t("reminder.filter.status")} value={activeFilter} onChange={(event) => setActiveFilter(event.target.value as ActiveFilter)} options={[{ value: "ALL", label: t("reminder.filter.allStatuses") }, { value: "ACTIVE", label: t("reminder.active") }, { value: "PAUSED", label: t("reminder.paused") }]} /></Card><section className="flex flex-col gap-3" aria-labelledby="reminder-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="reminder-list-title" className="clay-title-h2">{t("reminder.list")}</h2><p className="clay-caption">{t("common.results", { count: remindersQuery.data?.meta.total || 0 })}</p></div>{remindersQuery.isFetching && !remindersQuery.isLoading && <span className="clay-caption animate-pulse">{t("reminder.syncing")}</span>}</div>{renderState("reminder", remindersQuery.isLoading, remindersQuery.isError, reminders.length === 0, () => { void remindersQuery.refetch(); })}{reminders.map((item) => <ReminderCard key={item.id} reminder={item} t={t} formatDate={formatDate} isProcessing={mutationPending} onEdit={() => { setEditingReminder(item); setIsReminderFormOpen(true); }} onToggle={() => updateReminderMutation.mutate({ id: item.id, input: { isActive: !item.isActive } }, { onSuccess: () => openSnackbar({ type: "success", text: t(item.isActive ? "reminder.pauseSuccess" : "reminder.resumeSuccess") }), onError: (error) => showError(error, "reminder.saveFailed") })} onDelete={() => setDeleteTarget({ kind: "reminder", item })} />)}{reminderPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("reminder.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage <= 1 || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: reminderPage, total: reminderPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage >= reminderPages || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section></>}
{tab === "SETTINGS" && <>{settingQuery.isLoading && <div className="flex flex-col gap-3"><NotificationSkeleton /><NotificationSkeleton /></div>}{settingQuery.isError && renderState("notification", false, true, false, () => { void settingQuery.refetch(); })}{settingQuery.data?.data && <NotificationSettings setting={settingQuery.data.data} isSaving={updateSettingMutation.isPending} onSave={(setting) => updateSettingMutation.mutate(setting, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.settings.saveSuccess") }), onError: (error) => showError(error, "notification.settings.saveFailed") })} />}</>}
</main>
<ReminderFormModal isOpen={isReminderFormOpen} reminder={editingReminder} isSubmitting={createReminderMutation.isPending || updateReminderMutation.isPending} onClose={() => { setIsReminderFormOpen(false); setEditingReminder(undefined); }} onSubmit={handleReminderSubmit} />
<Modal isOpen={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} title={deleteTarget?.kind === "reminder" ? t("reminder.deleteTitle") : t("notification.deleteTitle")} footer={<><Button variant="ghost" className="px-4 text-sm" disabled={deleteNotificationMutation.isPending || deleteReminderMutation.isPending} onClick={() => setDeleteTarget(null)}>{t("common.cancel")}</Button><Button className="!bg-clay-expense px-4 text-sm" disabled={deleteNotificationMutation.isPending || deleteReminderMutation.isPending} onClick={confirmDelete}>{t("common.delete")}</Button></>}><p className="clay-body">{deleteTarget?.kind === "reminder" ? t("reminder.deleteHint") : t("notification.deleteHint")}</p></Modal>
</Page>
);
};
export default NotificationsPage;
......@@ -16,7 +16,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { IconGradients } from "@/components/ui/icons";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import {
AvatarEditorModal,
AvatarPosition,
......@@ -74,6 +74,13 @@ const ProfilePage: React.FC = () => {
x: user?.avatarPositionX ?? 50,
y: user?.avatarPositionY ?? 50,
});
const [passwordVisibility, setPasswordVisibility] = useState<
Record<keyof PasswordFormValues, boolean>
>({
oldPassword: false,
newPassword: false,
confirmPassword: false,
});
const tabs = [
{ key: "profile", label: t("profile.tabs.info") },
......@@ -217,6 +224,11 @@ const ProfilePage: React.FC = () => {
text: t("profile.passwordSuccess"),
});
resetPasswordForm();
setPasswordVisibility({
oldPassword: false,
newPassword: false,
confirmPassword: false,
});
},
onError: (error: unknown) => {
openSnackbar({
......@@ -336,6 +348,28 @@ const ProfilePage: React.FC = () => {
});
};
const renderPasswordVisibilityToggle = (field: keyof PasswordFormValues) => {
const isVisible = passwordVisibility[field];
return (
<button
type="button"
aria-label={t(isVisible ? "profile.hidePassword" : "profile.showPassword")}
aria-pressed={isVisible}
onClick={() =>
setPasswordVisibility((current) => ({
...current,
[field]: !current[field],
}))
}
disabled={updatePasswordMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
);
};
return (
<Page className="page">
<Header title={t("profile.header")} showBackIcon={true} onBackClick={() => navigate("/")} />
......@@ -430,28 +464,34 @@ const ProfilePage: React.FC = () => {
<Input
label={t("profile.currentPassword")}
placeholder={t("profile.currentPasswordPlaceholder")}
type="password"
type={passwordVisibility.oldPassword ? "text" : "password"}
autoComplete="current-password"
error={passwordErrors.oldPassword?.message}
{...registerPassword("oldPassword")}
disabled={updatePasswordMutation.isPending}
endAdornment={renderPasswordVisibilityToggle("oldPassword")}
/>
<Input
label={t("profile.newPassword")}
placeholder={t("profile.newPasswordPlaceholder")}
type="password"
type={passwordVisibility.newPassword ? "text" : "password"}
autoComplete="new-password"
error={passwordErrors.newPassword?.message}
{...registerPassword("newPassword")}
disabled={updatePasswordMutation.isPending}
endAdornment={renderPasswordVisibilityToggle("newPassword")}
/>
<Input
label={t("profile.confirmNewPassword")}
placeholder={t("profile.confirmNewPasswordPlaceholder")}
type="password"
type={passwordVisibility.confirmPassword ? "text" : "password"}
autoComplete="new-password"
error={passwordErrors.confirmPassword?.message}
{...registerPassword("confirmPassword")}
disabled={updatePasswordMutation.isPending}
endAdornment={renderPasswordVisibilityToggle("confirmPassword")}
/>
<Button
......
import React, { useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { Header, Page, useLocation, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
......@@ -20,6 +20,7 @@ import {
} from "@/hooks/use-saving-goals";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { isFromNotifications } from "@/lib/navigation-state";
import { toLocalDate } from "@/lib/money-input";
import {
CreateSavingGoalInput,
......@@ -46,6 +47,7 @@ const SavingGoalDetailPage: React.FC = () => {
const params = useParams<{ id: string }>();
const goalId = params.id || "";
const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar();
const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const [isEditOpen, setIsEditOpen] = useState(false);
......@@ -187,7 +189,7 @@ const SavingGoalDetailPage: React.FC = () => {
return (
<Page className="page">
<Header title={t("savingGoal.detailHeader")} showBackIcon onBackClick={() => navigate("/saving-goals")} />
<Header title={t("savingGoal.detailHeader")} showBackIcon onBackClick={() => isFromNotifications(location.state) ? navigate(-1) : navigate("/saving-goals")} />
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
......@@ -125,7 +125,10 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const formattedDate = () => {
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${transaction.date}T00:00:00Z`));
} catch {
......
import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
......@@ -20,6 +20,7 @@ import {
import { useWallets, walletKeys } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
import { getErrorMessage } from "@/lib/error-message";
import { isFromNotifications } from "@/lib/navigation-state";
import {
CreateTransactionInput,
Transaction,
......@@ -93,6 +94,7 @@ function parseAmountInput(value: string, locale: string): string {
const TransactionsPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { openSnackbar } = useSnackbar();
const { t, intlLocale } = useI18n();
......@@ -249,7 +251,10 @@ const TransactionsPage: React.FC = () => {
try {
return new Intl.DateTimeFormat(intlLocale, {
dateStyle: "full",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(`${dateStr}T00:00:00Z`));
} catch {
return dateStr;
......@@ -417,7 +422,7 @@ const TransactionsPage: React.FC = () => {
<Header
title={t("transaction.header")}
showBackIcon
onBackClick={() => navigate("/")}
onBackClick={() => isFromNotifications(location.state) ? navigate(-1) : navigate("/")}
/>
<IconGradients />
......
import { apiClient } from "@/lib/api-client";
import {
NotificationCountResponse,
NotificationDeleteResponse,
NotificationListResponse,
NotificationQuery,
NotificationResponse,
NotificationSettingResponse,
UpdateNotificationSettingInput,
} from "@/types/notification";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) throw new Error(response.message || "Notification request failed");
return response;
}
export const notificationService = {
async getNotifications(query: NotificationQuery): Promise<NotificationListResponse> {
const response = await apiClient.get<NotificationListResponse>("/notifications", { params: query });
return ensureSuccess(response.data);
},
async getUnreadCount(): Promise<NotificationCountResponse> {
const response = await apiClient.get<NotificationCountResponse>("/notifications/unread-count");
return ensureSuccess(response.data);
},
async markRead(id: string): Promise<NotificationResponse> {
const response = await apiClient.patch<NotificationResponse>(`/notifications/${id}/read`);
return ensureSuccess(response.data);
},
async markAllRead(): Promise<NotificationCountResponse> {
const response = await apiClient.patch<NotificationCountResponse>("/notifications/read-all");
return ensureSuccess(response.data);
},
async remove(id: string): Promise<NotificationDeleteResponse> {
const response = await apiClient.delete<NotificationDeleteResponse>(`/notifications/${id}`);
return ensureSuccess(response.data);
},
async getSetting(): Promise<NotificationSettingResponse> {
const response = await apiClient.get<NotificationSettingResponse>("/notifications/settings");
return ensureSuccess(response.data);
},
async updateSetting(input: UpdateNotificationSettingInput): Promise<NotificationSettingResponse> {
const response = await apiClient.put<NotificationSettingResponse>("/notifications/settings", input);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import {
CreateReminderInput,
ReminderDeleteResponse,
ReminderListResponse,
ReminderQuery,
ReminderResponse,
UpdateReminderInput,
} from "@/types/reminder";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) throw new Error(response.message || "Reminder request failed");
return response;
}
export const reminderService = {
async getReminders(query: ReminderQuery): Promise<ReminderListResponse> {
const response = await apiClient.get<ReminderListResponse>("/reminders", { params: query });
return ensureSuccess(response.data);
},
async create(input: CreateReminderInput): Promise<ReminderResponse> {
const response = await apiClient.post<ReminderResponse>("/reminders", input);
return ensureSuccess(response.data);
},
async update(id: string, input: UpdateReminderInput): Promise<ReminderResponse> {
const response = await apiClient.put<ReminderResponse>(`/reminders/${id}`, input);
return ensureSuccess(response.data);
},
async remove(id: string): Promise<ReminderDeleteResponse> {
const response = await apiClient.delete<ReminderDeleteResponse>(`/reminders/${id}`);
return ensureSuccess(response.data);
},
};
import { PaginationMeta } from "./wallet";
export type NotificationType =
| "BUDGET_NEAR_LIMIT"
| "BUDGET_EXCEEDED"
| "SAVING_GOAL_NEAR_TARGET"
| "SAVING_GOAL_ACHIEVED"
| "SAVING_GOAL_DUE_SOON"
| "RECURRING_PAYMENT_DUE"
| "UNUSUAL_TRANSACTION"
| "USER_REMINDER"
| "SYSTEM";
export type NotificationPriority = "LOW" | "NORMAL" | "HIGH" | "CRITICAL";
export type NotificationChannel = "IN_APP" | "EMAIL" | "ZALO" | "PUSH";
export type NotificationSourceType = "BUDGET" | "SAVING_GOAL" | "TRANSACTION" | "REMINDER" | "SYSTEM";
export type NotificationDeliveryStatus = "PENDING" | "PROCESSING" | "SENT" | "FAILED" | "SKIPPED";
export interface NotificationDelivery {
channel: NotificationChannel;
status: NotificationDeliveryStatus;
attemptCount: number;
sentAt: string | null;
}
export interface NotificationItem {
id: string;
type: NotificationType;
priority: NotificationPriority;
title: string;
message: string;
channels: NotificationChannel[];
data: Record<string, unknown> | null;
actionUrl: string | null;
sourceType: NotificationSourceType | null;
sourceId: string | null;
readAt: string | null;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
deliveries: NotificationDelivery[];
}
export interface NotificationQuery {
type?: NotificationType;
priority?: NotificationPriority;
isRead?: boolean;
page: number;
limit: number;
}
export interface NotificationSetting {
channels: NotificationChannel[];
budgetAlertsEnabled: boolean;
savingGoalAlertsEnabled: boolean;
reminderAlertsEnabled: boolean;
unusualTxnAlertsEnabled: boolean;
}
export type UpdateNotificationSettingInput = Partial<NotificationSetting>;
export interface NotificationListResponse {
success: boolean;
message?: string;
data: NotificationItem[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface NotificationResponse {
success: boolean;
message?: string;
data: NotificationItem;
errors?: unknown[] | null;
}
export interface NotificationSettingResponse {
success: boolean;
message?: string;
data: NotificationSetting;
errors?: unknown[] | null;
}
export interface NotificationCountResponse {
success: boolean;
message?: string;
data: { count: number };
errors?: unknown[] | null;
}
export interface NotificationDeleteResponse {
success: boolean;
message?: string;
data: { id: string };
errors?: unknown[] | null;
}
import { PaginationMeta } from "./wallet";
export type ReminderType = "GENERAL" | "RECURRING_PAYMENT";
export type ReminderFrequency = "ONCE" | "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
export interface Reminder {
id: string;
type: ReminderType;
title: string;
message: string | null;
remindAt: string;
frequency: ReminderFrequency;
repeatInterval: number;
endAt: string | null;
nextTriggerAt: string | null;
lastTriggeredAt: string | null;
actionUrl: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ReminderQuery {
type?: ReminderType;
isActive?: boolean;
dueFrom?: string;
dueTo?: string;
page: number;
limit: number;
}
export interface CreateReminderInput {
type: ReminderType;
title: string;
message?: string | null;
remindAt: string;
frequency: ReminderFrequency;
repeatInterval: number;
endAt?: string | null;
actionUrl?: string | null;
isActive: boolean;
}
export type UpdateReminderInput = Partial<CreateReminderInput>;
export interface ReminderListResponse {
success: boolean;
message?: string;
data: Reminder[];
meta: PaginationMeta;
errors?: unknown[] | null;
}
export interface ReminderResponse {
success: boolean;
message?: string;
data: Reminder;
errors?: unknown[] | null;
}
export interface ReminderDeleteResponse {
success: boolean;
message?: string;
data: { id: string };
errors?: unknown[] | null;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment