Commit d6fa2a5d authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/guest-trial-mode' into 'develop'

feat(guest): implement comprehensive guest trial mode with offline mock data and safe fallbacks

See merge request !62
parents 50269674 5720c6c1
...@@ -9,4 +9,9 @@ ...@@ -9,4 +9,9 @@
- [ ] Viết custom React Query hook để thực hiện query/mutation dữ liệu. - [ ] Viết custom React Query hook để thực hiện query/mutation dữ liệu.
- [ ] Xử lý các trạng thái: Loading (sử dụng Skeleton bo tròn), Error (nổi bật đất sét), và Empty (thông báo thân thiện). - [ ] Xử lý các trạng thái: Loading (sử dụng Skeleton bo tròn), Error (nổi bật đất sét), và Empty (thông báo thân thiện).
- [ ] Đảm bảo thiết kế responsive đầy đủ cho mọi kích thước màn hình điện thoại di động. - [ ] Đảm bảo thiết kế responsive đầy đủ cho mọi kích thước màn hình điện thoại di động.
- [ ] Chạy build kiểm tra lỗi TypeScript: `npm run build`. - [ ] **Tương thích Chế độ Khách (Guest Mode Compatibility - Quy định Zalo Mini App)**:
- [ ] Nếu route/hành động được bảo vệ bởi `PermissionGate`, đảm bảo permission đó đã có trong `GUEST_USER.permissions` (`src/stores/auth-store.ts`).
- [ ] Nếu trang gọi endpoint GET mới để lấy dữ liệu, bổ sung mock data/handler tương ứng trong `src/lib/guest-mock.ts` để khách trải nghiệm không bị lỗi mạng hay 401.
- [ ] Đảm bảo các query hook đọc dữ liệu an toàn bằng optional chaining (ví dụ: `query.data?.data?.items || []`) tránh null pointer crash khi data undefined.
- [ ] Chạy build kiểm tra lỗi TypeScript: `pnpm run build`.
...@@ -121,9 +121,21 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -121,9 +121,21 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Đầy đủ hỗ trợ đa ngôn ngữ Việt - Anh qua `src/i18n/` và cập nhật tiêu đề trang `DocumentTitle`; toàn bộ schema validation Zod, các thẻ dữ liệu, nút hành động, bảng phân bổ, hộp thoại thanh toán/tất toán, thông báo lỗi/thành công và đơn vị tính (%/năm, %/yr, tháng, months, kỳ, periods) đều được chuẩn hóa qua i18n (`vi.json` và `en.json`), tuyệt đối không còn hardcode chuỗi ký tự thô. - Đầy đủ hỗ trợ đa ngôn ngữ Việt - Anh qua `src/i18n/` và cập nhật tiêu đề trang `DocumentTitle`; toàn bộ schema validation Zod, các thẻ dữ liệu, nút hành động, bảng phân bổ, hộp thoại thanh toán/tất toán, thông báo lỗi/thành công và đơn vị tính (%/năm, %/yr, tháng, months, kỳ, periods) đều được chuẩn hóa qua i18n (`vi.json` và `en.json`), tuyệt đối không còn hardcode chuỗi ký tự thô.
- Tách bạch Modal Tạo Mới và Trình Mô Phỏng: Tại modal "Tạo Khoản Nợ / Cho Vay Mới" (chế độ `create`), giao diện tập trung trực tiếp vào form nhập liệu tinh gọn, ẩn hoàn toàn thanh Tabs và lược bỏ tab "Dự tính lịch trả nợ"; chức năng dự tính lịch trả nợ và live preview API query chỉ kích hoạt khi người dùng mở qua nút "Mô phỏng trả nợ" (chế độ `simulator`). - Tách bạch Modal Tạo Mới và Trình Mô Phỏng: Tại modal "Tạo Khoản Nợ / Cho Vay Mới" (chế độ `create`), giao diện tập trung trực tiếp vào form nhập liệu tinh gọn, ẩn hoàn toàn thanh Tabs và lược bỏ tab "Dự tính lịch trả nợ"; chức năng dự tính lịch trả nợ và live preview API query chỉ kích hoạt khi người dùng mở qua nút "Mô phỏng trả nợ" (chế độ `simulator`).
- Chức năng Chỉnh sửa Hợp đồng Nợ (`EditDebtModal`): Cho phép cập nhật các thông tin phi tài chính (Tên khoản nợ, Đối tác, Ví liên kết và Ghi chú) qua `PUT /api/v1/debts/:id`. Các thông số tài chính cốt lõi (Gốc, Lãi suất, Kỳ hạn, Phương thức) được cố định bất biến (Immutable Invariants) kèm thông báo giải thích rõ ràng nhằm bảo toàn tính toàn vẹn của động cơ lịch trả nợ và hạch toán số dư ví. Nút "Chỉnh sửa" được bố trí thuận tiện tại cả phần đầu thẻ (quick action) và hàng nút hành động. - Chức năng Chỉnh sửa Hợp đồng Nợ (`EditDebtModal`): Cho phép cập nhật các thông tin phi tài chính (Tên khoản nợ, Đối tác, Ví liên kết và Ghi chú) qua `PUT /api/v1/debts/:id`. Các thông số tài chính cốt lõi (Gốc, Lãi suất, Kỳ hạn, Phương thức) được cố định bất biến (Immutable Invariants) kèm thông báo giải thích rõ ràng nhằm bảo toàn tính toàn vẹn của động cơ lịch trả nợ và hạch toán số dư ví. Nút "Chỉnh sửa" được bố trí thuận tiện tại cả phần đầu thẻ (quick action) và hàng nút hành động.
- **Chế độ Khách & Tuân thủ Xét duyệt Zalo Mini App (Guest Mode & Zalo Review Compliance)**:
- Để tuân thủ chính sách kiểm duyệt của Zalo Mini App (không chặn cứng người dùng ở màn hình đăng nhập, phải thể hiện rõ thông tin/ngữ cảnh và có lối vào trải nghiệm cho người dùng từ chối cấp quyền):
- Màn hình `/login` cung cấp khối giới thiệu ứng dụng, nêu rõ lý do cần tài khoản để đồng bộ đám mây và bảo mật sổ thu chi, cùng nút nổi bật **"Khám phá ngay (Chế độ Dùng thử / Khách)"**.
- Trạng thái `isGuest` được quản lý trong Zustand `auth-store` và lưu vào `safeStorage` (`finwise.isGuest`).
- `AuthGuard` cho phép người dùng truy cập vào ứng dụng khi `isAuthenticated || isGuest`.
- `apiClient` tự động chuyển tiếp các request sang `handleGuestRequest` (`src/lib/guest-mock.ts`) khi `isGuest === true`, đảm bảo trải nghiệm mượt mà với dữ liệu mẫu chân thực (ví tiền, danh mục, giao dịch, ngân sách, mục tiêu, báo cáo) mà không bị lỗi 401 hay lỗi mạng.
- Banner `GuestModeBanner` xuất hiện ở đầu Trang chủ để thông báo người dùng đang xem dữ liệu mẫu và cung cấp lối tắt quay lại đăng nhập bất cứ lúc nào.
- **QUY TẮC BẮT BUỘC KHI THÊM TÍNH NĂNG MỚI**:
1. Bổ sung quyền của tính năng mới vào danh sách `GUEST_USER.permissions` trong `src/stores/auth-store.ts` để `PermissionGate` không chặn khách.
2. Bổ sung mock data phản hồi cho endpoint GET mới vào `src/lib/guest-mock.ts`.
3. Đọc dữ liệu từ query cache luôn dùng optional chaining `?.` để tránh lỗi null/undefined crash.
## Khi cập nhật file này ## Khi cập nhật file này
- Ghi lại các quyết định lâu dài mới về UI/UX hoặc cấu trúc frontend. - Ghi lại các quyết định lâu dài mới về UI/UX hoặc cấu trúc frontend.
- Cập nhật danh sách thư viện hoặc thay đổi lớn trong phương thức kết nối API. - Cập nhật danh sách thư viện hoặc thay đổi lớn trong phương thức kết nối API.
...@@ -32,3 +32,12 @@ ...@@ -32,3 +32,12 @@
### Form State & Validation (Zod + React Hook Form) ### Form State & Validation (Zod + React Hook Form)
- Biện kiểm soát dữ liệu đầu vào: Tất cả các form thêm/sửa giao dịch, thiết lập ví, đặt ngân sách phải được validate bằng Zod schema trước khi submit. - Biện kiểm soát dữ liệu đầu vào: Tất cả các form thêm/sửa giao dịch, thiết lập ví, đặt ngân sách phải được validate bằng Zod schema trước khi submit.
- Định nghĩa schema rõ ràng trong file component hoặc `<feature>.validation.ts`. - Định nghĩa schema rõ ràng trong file component hoặc `<feature>.validation.ts`.
## 3. Chế độ Khách & Tuân thủ Kiểm duyệt Zalo Mini App (Guest Mode)
- **Quy định Zalo**: Mini App không được chặn cứng người dùng bằng màn hình đăng nhập nếu không có giải thích ngữ cảnh hoặc không có chế độ dùng thử.
- **Quy tắc khi tạo tính năng/trang mới**:
1. **Permissions**: Mọi tính năng mới cho người dùng thông thường (`USER`) phải được khai báo quyền trong `GUEST_USER.permissions` (`src/stores/auth-store.ts`) để `PermissionGate` không từ chối truy cập khách.
2. **Mock Data**: Bổ sung endpoint GET mới vào `handleGuestRequest` trong `src/lib/guest-mock.ts` với dữ liệu mẫu chân thực, tránh để khách gặp lỗi mạng hoặc 401.
3. **An toàn dữ liệu**: Đọc dữ liệu từ React Query luôn dùng optional chaining (`data?.data?.items || []`) để không gây crash ứng dụng khi dữ liệu rỗng hoặc chưa tải xong.
4. **Thông báo nhẹ nhàng**: Các thao tác ghi (POST/PUT/DELETE) ở chế độ khách không báo lỗi crash mà nhắc nhở thân thiện yêu cầu đăng nhập để lưu trữ vĩnh viễn trên đám mây.
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
} from "zmp-ui"; } from "zmp-ui";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore, GUEST_STORAGE_KEY } from "@/stores/auth-store";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { safeStorage } from "@/lib/storage"; import { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard"; import { AuthGuard } from "@/components/shared/AuthGuard";
...@@ -89,9 +89,15 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) ...@@ -89,9 +89,15 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
try { try {
const isGuest = safeStorage.getItem(GUEST_STORAGE_KEY) === "true";
const storedAccessToken = safeStorage.getItem("accessToken"); const storedAccessToken = safeStorage.getItem("accessToken");
const storedRefreshToken = safeStorage.getItem("refreshToken"); const storedRefreshToken = safeStorage.getItem("refreshToken");
// If in guest mode and no real token, maintain guest state and finish initializing
if (isGuest && !storedAccessToken) {
return;
}
// If no tokens exist in storage, user is not logged in // If no tokens exist in storage, user is not logged in
if (!storedAccessToken && !storedRefreshToken) { if (!storedAccessToken && !storedRefreshToken) {
clearAuth(); clearAuth();
......
...@@ -5,15 +5,15 @@ import { useI18n } from "@/i18n"; ...@@ -5,15 +5,15 @@ import { useI18n } from "@/i18n";
import { Logo } from "@/components/logo"; import { Logo } from "@/components/logo";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore(); const { isAuthenticated, isGuest, isInitialized } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
useEffect(() => { useEffect(() => {
if (isInitialized && !isAuthenticated) { if (isInitialized && !isAuthenticated && !isGuest) {
navigate("/login", { replace: true }); navigate("/login", { replace: true });
} }
}, [isInitialized, isAuthenticated, navigate]); }, [isInitialized, isAuthenticated, isGuest, navigate]);
if (!isInitialized) { if (!isInitialized) {
return ( return (
...@@ -24,7 +24,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) ...@@ -24,7 +24,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
); );
} }
if (!isAuthenticated) { if (!isAuthenticated && !isGuest) {
return null; return null;
} }
......
import React from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
import { useI18n } from "@/i18n";
import { SparklesIcon } from "@/components/ui/icons";
export const GuestModeBanner: React.FC<{ className?: string }> = ({ className = "" }) => {
const { isGuest } = useAuthStore();
const navigate = useNavigate();
const { t, locale } = useI18n();
if (!isGuest) return null;
const isEn = locale === "en";
const badgeText =
t("guest.badge") !== "guest.badge"
? t("guest.badge")
: t("auth.guest.badge") !== "auth.guest.badge"
? t("auth.guest.badge")
: isEn
? "Demo Mode"
: "Chế độ Dùng thử";
const bannerText =
t("guest.bannerMessage") !== "guest.bannerMessage"
? t("guest.bannerMessage")
: t("auth.guest.bannerMessage") !== "auth.guest.bannerMessage"
? t("auth.guest.bannerMessage")
: isEn
? "You are exploring FinWise with sample data. Data is not synced to the cloud."
: "Bạn đang trải nghiệm FinWise với dữ liệu mẫu. Dữ liệu chưa được đồng bộ đám mây.";
const loginText =
t("guest.loginNow") !== "guest.loginNow"
? t("guest.loginNow")
: t("auth.guest.loginNow") !== "auth.guest.loginNow"
? t("auth.guest.loginNow")
: isEn
? "Sign in now"
: "Đăng nhập ngay";
return (
<div className={`w-full max-w-sm mx-auto my-2 rounded-2xl bg-amber-500/10 border border-amber-500/30 p-3.5 shadow-clay-card flex flex-col sm:flex-row sm:items-center justify-between gap-3 animate-fade-in ${className}`}>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400">
<SparklesIcon size={16} />
</div>
<div className="space-y-1 text-left">
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center h-5 rounded-md bg-amber-500/20 border border-amber-500/30 px-2 font-nunito text-[11px] font-bold text-amber-700 dark:text-amber-300 uppercase tracking-wide leading-none">
{badgeText}
</span>
</div>
<p className="font-nunito text-xs text-clay-text-muted leading-relaxed">
{bannerText}
</p>
</div>
</div>
<button
type="button"
onClick={() => navigate("/login")}
className="shrink-0 self-end sm:self-center px-3.5 py-1.5 rounded-xl bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-nunito text-xs font-bold shadow-sm transition-all duration-150"
>
{loginText}
</button>
</div>
);
};
...@@ -587,6 +587,24 @@ export const ChartBarIcon: React.FC<IconProps> = ({ size = 18, ...props }) => ( ...@@ -587,6 +587,24 @@ export const ChartBarIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
</svg> </svg>
); );
// Sparkles Icon
export const SparklesIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3L12 3z" />
</svg>
);
......
...@@ -43,6 +43,13 @@ ...@@ -43,6 +43,13 @@
"switchToLight": "Switch to light mode", "switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode" "switchToDark": "Switch to dark mode"
}, },
"guest": {
"badge": "Demo Mode",
"bannerMessage": "You are exploring FinWise with sample data. Data is not synced to the cloud.",
"loginNow": "Sign in now",
"exitGuest": "Exit guest mode",
"guestProfileNote": "Temporary guest account. Sign in to permanently save your financial records."
},
"document": { "document": {
"default": "Expense Journal & Financial Reports", "default": "Expense Journal & Financial Reports",
"home": "Home", "home": "Home",
...@@ -171,7 +178,19 @@ ...@@ -171,7 +178,19 @@
"wallet": "Manage Wallets", "wallet": "Manage Wallets",
"report": "Reports", "report": "Reports",
"ai": "AI Assistant" "ai": "AI Assistant"
} },
"appDescription": "Personal Finance & Expense Management",
"contextNotice": "FinWise requires an account to secure your wallet data and synchronize personal expenses to the cloud. You can sign in or explore the app in demo mode below.",
"guestExplore": "Explore Now",
"guestExploreSubtext": "Experience the app with sample data, no sign-in required",
"orEmail": "Or sign in with Email"
},
"guest": {
"badge": "Demo Mode",
"bannerMessage": "You are exploring FinWise with sample data. Data is not synced to the cloud.",
"loginNow": "Sign in now",
"exitGuest": "Exit guest mode",
"guestProfileNote": "Temporary guest account. Sign in to permanently save your financial records."
}, },
"zalo": { "zalo": {
"loginButton": "Link Account", "loginButton": "Link Account",
...@@ -237,7 +256,7 @@ ...@@ -237,7 +256,7 @@
} }
}, },
"home": { "home": {
"header": "FinWise Mini App", "header": "FinWise",
"greeting": "Hello, {{name}}!", "greeting": "Hello, {{name}}!",
"subtitle": "Smart Financial Management & Runway Forecasting", "subtitle": "Smart Financial Management & Runway Forecasting",
"account": "Account:", "account": "Account:",
......
...@@ -43,6 +43,13 @@ ...@@ -43,6 +43,13 @@
"switchToLight": "Chuyển sang giao diện sáng", "switchToLight": "Chuyển sang giao diện sáng",
"switchToDark": "Chuyển sang giao diện tối" "switchToDark": "Chuyển sang giao diện tối"
}, },
"guest": {
"badge": "Chế độ Dùng thử",
"bannerMessage": "Bạn đang trải nghiệm FinWise với dữ liệu mẫu. Dữ liệu chưa được đồng bộ đám mây.",
"loginNow": "Đăng nhập ngay",
"exitGuest": "Thoát chế độ khách",
"guestProfileNote": "Tài khoản khách tạm thời. Đăng nhập để lưu trữ vĩnh viễn sổ thu chi của bạn."
},
"document": { "document": {
"default": "Sổ tay Chi tiêu & Báo cáo Tài chính", "default": "Sổ tay Chi tiêu & Báo cáo Tài chính",
"home": "Trang chủ", "home": "Trang chủ",
...@@ -171,7 +178,19 @@ ...@@ -171,7 +178,19 @@
"wallet": "Quản lý ví", "wallet": "Quản lý ví",
"report": "Báo cáo", "report": "Báo cáo",
"ai": "Trợ lý AI" "ai": "Trợ lý AI"
} },
"appDescription": "Ứng dụng Quản lý Tài chính & Sổ thu chi Cá nhân",
"contextNotice": "FinWise cần tài khoản để bảo mật dữ liệu ví tiền và đồng bộ sổ thu chi cá nhân của bạn trên đám mây. Bạn có thể đăng nhập hoặc tiếp tục trải nghiệm ở chế độ dùng thử bên dưới.",
"guestExplore": "Khám phá ngay",
"guestExploreSubtext": "Trải nghiệm tính năng với dữ liệu mẫu, không cần đăng nhập",
"orEmail": "Hoặc đăng nhập bằng tài khoản Email"
},
"guest": {
"badge": "Chế độ Dùng thử",
"bannerMessage": "Bạn đang trải nghiệm FinWise với dữ liệu mẫu. Dữ liệu chưa được đồng bộ đám mây.",
"loginNow": "Đăng nhập ngay",
"exitGuest": "Thoát chế độ khách",
"guestProfileNote": "Tài khoản khách tạm thời. Đăng nhập để lưu trữ vĩnh viễn sổ thu chi của bạn."
}, },
"zalo": { "zalo": {
"loginButton": "Liên kết tài khoản", "loginButton": "Liên kết tài khoản",
...@@ -237,7 +256,7 @@ ...@@ -237,7 +256,7 @@
} }
}, },
"home": { "home": {
"header": "FinWise Mini App", "header": "FinWise",
"greeting": "Xin chào, {{name}}!", "greeting": "Xin chào, {{name}}!",
"subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền", "subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền",
"account": "Tài khoản:", "account": "Tài khoản:",
......
...@@ -25,9 +25,25 @@ const refreshClient = axios.create({ ...@@ -25,9 +25,25 @@ const refreshClient = axios.create({
}, },
}); });
import { handleGuestRequest } from "@/lib/guest-mock";
// Request interceptor to attach bearer token // Request interceptor to attach bearer token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
// If in Guest Mode and not an auth endpoint, serve rich sample data
const isGuest = useAuthStore.getState().isGuest;
const isAuthEndpoint =
config.url?.includes("/auth/login") ||
config.url?.includes("/auth/register") ||
config.url?.includes("/auth/zalo-login") ||
config.url?.includes("/auth/forgot-password") ||
config.url?.includes("/auth/reset-password");
if (isGuest && !isAuthEndpoint) {
config.adapter = async () => handleGuestRequest(config);
return config;
}
const accessToken = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken"); const accessToken = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
if (accessToken && config.headers) { if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
......
import { AxiosResponse, InternalAxiosRequestConfig } from "axios";
import { GUEST_USER } from "@/stores/auth-store";
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = String(now.getMonth() + 1).padStart(2, "0");
const todayStr = `${currentYear}-${currentMonth}-${String(now.getDate()).padStart(2, "0")}`;
const yesterdayStr = `${currentYear}-${currentMonth}-${String(Math.max(1, now.getDate() - 1)).padStart(2, "0")}`;
const startOfMonthStr = `${currentYear}-${currentMonth}-01`;
const endOfMonthStr = `${currentYear}-${currentMonth}-28`;
const GUEST_WALLETS = [
{
id: "wallet-guest-1",
name: "Ví Tiền Mặt",
balance: "5500000",
currency: "VND",
icon: "cash",
color: "#10B981",
description: "Tiền mặt tiêu dùng hàng ngày",
isDefault: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "wallet-guest-2",
name: "Techcombank",
balance: "38200000",
currency: "VND",
icon: "bank",
color: "#3B82F6",
description: "Tài khoản nhận lương chính",
isDefault: false,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "wallet-guest-3",
name: "Quỹ Tiết Kiệm",
balance: "120000000",
currency: "VND",
icon: "savings",
color: "#8B5CF6",
description: "Tài khoản tích lũy dài hạn",
isDefault: false,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const GUEST_CATEGORIES = [
{
id: "cat-income-1",
parentId: null,
name: "Lương & Thu nhập",
type: "INCOME" as const,
color: "#10B981",
icon: "wallet",
isSystem: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [
{
id: "cat-sub-1",
parentId: "cat-income-1",
name: "Lương chính",
type: "INCOME" as const,
color: "#10B981",
icon: "briefcase",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-2",
parentId: "cat-income-1",
name: "Thưởng dự án",
type: "INCOME" as const,
color: "#34D399",
icon: "gift",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "cat-expense-1",
parentId: null,
name: "Ăn uống",
type: "EXPENSE" as const,
color: "#F59E0B",
icon: "utensils",
isSystem: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [
{
id: "cat-sub-3",
parentId: "cat-expense-1",
name: "Cà phê & Đồ uống",
type: "EXPENSE" as const,
color: "#D97706",
icon: "coffee",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-4",
parentId: "cat-expense-1",
name: "Ăn trưa & Tối",
type: "EXPENSE" as const,
color: "#F59E0B",
icon: "restaurant",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-5",
parentId: "cat-expense-1",
name: "Đi siêu thị",
type: "EXPENSE" as const,
color: "#B45309",
icon: "cart",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "cat-expense-2",
parentId: null,
name: "Di chuyển",
type: "EXPENSE" as const,
color: "#3B82F6",
icon: "car",
isSystem: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [
{
id: "cat-sub-6",
parentId: "cat-expense-2",
name: "Xăng xe",
type: "EXPENSE" as const,
color: "#2563EB",
icon: "fuel",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-7",
parentId: "cat-expense-2",
name: "Gửi xe",
type: "EXPENSE" as const,
color: "#1D4ED8",
icon: "parking",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "cat-expense-3",
parentId: null,
name: "Hóa đơn & Tiện ích",
type: "EXPENSE" as const,
color: "#EC4899",
icon: "receipt",
isSystem: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [
{
id: "cat-sub-8",
parentId: "cat-expense-3",
name: "Internet & 4G",
type: "EXPENSE" as const,
color: "#DB2777",
icon: "wifi",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-9",
parentId: "cat-expense-3",
name: "Điện nước",
type: "EXPENSE" as const,
color: "#BE185D",
icon: "bolt",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "cat-expense-4",
parentId: null,
name: "Mua sắm",
type: "EXPENSE" as const,
color: "#8B5CF6",
icon: "shopping-bag",
isSystem: true,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
children: [
{
id: "cat-sub-10",
parentId: "cat-expense-4",
name: "Quần áo & Phụ kiện",
type: "EXPENSE" as const,
color: "#7C3AED",
icon: "tag",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "cat-sub-11",
parentId: "cat-expense-4",
name: "Đồ công nghệ",
type: "EXPENSE" as const,
color: "#6D28D9",
icon: "laptop",
isSystem: false,
isArchived: false,
children: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
];
const GUEST_TRANSACTIONS = [
{
id: "tx-guest-1",
walletId: "wallet-guest-1",
categoryId: "cat-sub-3",
amount: "35000",
type: "EXPENSE" as const,
category: { id: "cat-sub-3", name: "Cà phê & Đồ uống", type: "EXPENSE" as const, color: "#D97706", icon: "coffee" },
wallet: { id: "wallet-guest-1", name: "Ví Tiền Mặt", currency: "VND" },
date: todayStr,
transactionDate: todayStr,
note: "Cà phê sáng cùng bạn bè",
description: "Cà phê sáng",
receiptUrl: null,
location: "Highlands Coffee",
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-2",
walletId: "wallet-guest-2",
categoryId: "cat-sub-5",
amount: "450000",
type: "EXPENSE" as const,
category: { id: "cat-sub-5", name: "Đi siêu thị", type: "EXPENSE" as const, color: "#B45309", icon: "cart" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: todayStr,
transactionDate: todayStr,
note: "Mua thực phẩm tuần mới tại WinMart",
description: "Siêu thị WinMart",
receiptUrl: null,
location: "WinMart+",
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-3",
walletId: "wallet-guest-1",
categoryId: "cat-sub-6",
amount: "85000",
type: "EXPENSE" as const,
category: { id: "cat-sub-6", name: "Xăng xe", type: "EXPENSE" as const, color: "#2563EB", icon: "fuel" },
wallet: { id: "wallet-guest-1", name: "Ví Tiền Mặt", currency: "VND" },
date: yesterdayStr,
transactionDate: yesterdayStr,
note: "Đổ xăng xe máy",
description: "Đổ xăng Petrolimex",
receiptUrl: null,
location: "Petrolimex Cây Xăng",
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-4",
walletId: "wallet-guest-2",
categoryId: "cat-sub-1",
amount: "25000000",
type: "INCOME" as const,
category: { id: "cat-sub-1", name: "Lương chính", type: "INCOME" as const, color: "#10B981", icon: "briefcase" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: `${currentYear}-${currentMonth}-05`,
transactionDate: `${currentYear}-${currentMonth}-05`,
note: "Lương chuyển khoản tháng này",
description: "Nhận lương công ty",
receiptUrl: null,
location: null,
isRecurring: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-5",
walletId: "wallet-guest-2",
categoryId: "cat-sub-8",
amount: "220000",
type: "EXPENSE" as const,
category: { id: "cat-sub-8", name: "Internet & 4G", type: "EXPENSE" as const, color: "#DB2777", icon: "wifi" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: `${currentYear}-${currentMonth}-10`,
transactionDate: `${currentYear}-${currentMonth}-10`,
note: "Cước Internet FPT cáp quang",
description: "Tiền mạng FPT",
receiptUrl: null,
location: null,
isRecurring: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-6",
walletId: "wallet-guest-2",
categoryId: "cat-sub-4",
amount: "580000",
type: "EXPENSE" as const,
category: { id: "cat-sub-4", name: "Ăn trưa & Tối", type: "EXPENSE" as const, color: "#F59E0B", icon: "restaurant" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: `${currentYear}-${currentMonth}-12`,
transactionDate: `${currentYear}-${currentMonth}-12`,
note: "Ăn tối liên hoan cuối tuần",
description: "Nhà hàng lẩu nướng",
receiptUrl: null,
location: "King BBQ",
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-7",
walletId: "wallet-guest-2",
categoryId: "cat-sub-10",
amount: "790000",
type: "EXPENSE" as const,
category: { id: "cat-sub-10", name: "Quần áo & Phụ kiện", type: "EXPENSE" as const, color: "#7C3AED", icon: "tag" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: `${currentYear}-${currentMonth}-15`,
transactionDate: `${currentYear}-${currentMonth}-15`,
note: "Áo sơ mi Uniqlo",
description: "Mua sắm Uniqlo",
receiptUrl: null,
location: "Uniqlo Vincom",
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "tx-guest-8",
walletId: "wallet-guest-2",
categoryId: "cat-sub-2",
amount: "5000000",
type: "INCOME" as const,
category: { id: "cat-sub-2", name: "Thưởng dự án", type: "INCOME" as const, color: "#34D399", icon: "gift" },
wallet: { id: "wallet-guest-2", name: "Techcombank", currency: "VND" },
date: `${currentYear}-${currentMonth}-18`,
transactionDate: `${currentYear}-${currentMonth}-18`,
note: "Thưởng KPI quý",
description: "Thưởng dự án FinWise",
receiptUrl: null,
location: null,
isRecurring: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const GUEST_TRANSFERS = [
{
id: "transfer-guest-1",
sourceWalletId: "wallet-guest-2",
destinationWalletId: "wallet-guest-1",
amount: "2000000",
note: "Rút tiền mặt tiêu vặt",
transferredAt: `${yesterdayStr}T10:00:00Z`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
sourceWallet: GUEST_WALLETS[1],
destinationWallet: GUEST_WALLETS[0],
},
];
const GUEST_BUDGETS = [
{
id: "budget-guest-1",
name: "Ngân sách Ăn uống tháng này",
amount: "6000000",
currency: "VND",
type: "CATEGORY" as const,
period: "MONTHLY" as const,
categoryId: "cat-expense-1",
startDate: startOfMonthStr,
endDate: endOfMonthStr,
alertThreshold: "80",
isArchived: false,
isRecurring: true,
autoRenew: true,
recurrenceGroupId: null,
rolloverMode: "RESET" as const,
rolloverAmount: "0",
autoRenewUntil: null,
renewedAt: null,
parentBudgetId: null,
category: { id: "cat-expense-1", name: "Ăn uống", type: "EXPENSE" as const, color: "#F59E0B", icon: "utensils" },
usage: {
spent: "3250000",
spentAmount: "3250000",
remaining: "2750000",
remainingAmount: "2750000",
usagePercentage: "54.17",
transactionCount: 3,
lastTransactionAt: todayStr,
timeStatus: "ACTIVE" as const,
status: "ON_TRACK" as const,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "budget-guest-2",
name: "Hạn mức Mua sắm cá nhân",
amount: "3000000",
currency: "VND",
type: "CATEGORY" as const,
period: "MONTHLY" as const,
categoryId: "cat-expense-4",
startDate: startOfMonthStr,
endDate: endOfMonthStr,
alertThreshold: "80",
isArchived: false,
isRecurring: true,
autoRenew: true,
recurrenceGroupId: null,
rolloverMode: "RESET" as const,
rolloverAmount: "0",
autoRenewUntil: null,
renewedAt: null,
parentBudgetId: null,
category: { id: "cat-expense-4", name: "Mua sắm", type: "EXPENSE" as const, color: "#8B5CF6", icon: "shopping-bag" },
usage: {
spent: "2650000",
spentAmount: "2650000",
remaining: "350000",
remainingAmount: "350000",
usagePercentage: "88.33",
transactionCount: 1,
lastTransactionAt: `${currentYear}-${currentMonth}-15`,
timeStatus: "ACTIVE" as const,
status: "NEAR_LIMIT" as const,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const GUEST_SAVING_GOALS = [
{
id: "goal-guest-1",
name: "Du lịch Đà Lạt mùa hoa",
targetAmount: "15000000",
currency: "VND",
targetDate: `${currentYear}-12-31`,
status: "ACTIVE" as const,
completedAt: null,
isArchived: false,
icon: "plane",
color: "#3B82F6",
description: "Chuyến đi nghỉ dưỡng cuối năm cùng gia đình",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
progress: {
savedAmount: "9500000",
remainingAmount: "5500000",
progressPercentage: "63.3",
contributionCount: 3,
lastContributionAt: todayStr,
daysRemaining: 90,
isOverdue: false,
},
},
{
id: "goal-guest-2",
name: "Quỹ dự phòng khẩn cấp",
targetAmount: "50000000",
currency: "VND",
targetDate: `${currentYear + 1}-06-30`,
status: "ACTIVE" as const,
completedAt: null,
isArchived: false,
icon: "shield",
color: "#10B981",
description: "Duy trì 6 tháng chi phí sinh hoạt tối thiểu",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
progress: {
savedAmount: "35000000",
remainingAmount: "15000000",
progressPercentage: "70.0",
contributionCount: 5,
lastContributionAt: yesterdayStr,
daysRemaining: 270,
isOverdue: false,
},
},
];
const GUEST_DEBTS = [
{
id: "debt-guest-1",
userId: "guest-user-session",
walletId: "wallet-guest-2",
wallet: GUEST_WALLETS[1],
name: "Cho Tuấn Anh vay làm ăn",
counterparty: "Tuấn Anh",
type: "LOAN_RECEIVABLE" as const,
method: "FIXED_ANNUITY" as const,
status: "ACTIVE" as const,
principal: "20000000",
remainingPrincipal: "10000000",
annualInterestRate: "6.0",
termMonths: 10,
startDate: `${currentYear}-01-15`,
endDate: `${currentYear}-10-15`,
notes: "Hợp đồng cho vay bạn thân hỗ trợ mở quán cafe",
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metrics: {
totalInstallments: 10,
paidInstallments: 5,
progressPercent: 50,
totalPaidPrincipal: 10000000,
totalPaidInterest: 550000,
remainingPrincipal: 10000000,
nextDueDate: `${currentYear}-${currentMonth}-15`,
nextDueAmount: 2050000,
},
scheduleItems: [
{
id: "sch-1-1",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 1,
dueDate: `${currentYear}-01-15`,
principalDue: "2000000",
interestDue: "100000",
totalDue: "2100000",
principalPaid: "2000000",
interestPaid: "100000",
remainingPrincipal: "18000000",
status: "PAID" as const,
paidDate: `${currentYear}-01-15`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-2",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 2,
dueDate: `${currentYear}-02-15`,
principalDue: "2000000",
interestDue: "90000",
totalDue: "2090000",
principalPaid: "2000000",
interestPaid: "90000",
remainingPrincipal: "16000000",
status: "PAID" as const,
paidDate: `${currentYear}-02-15`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-3",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 3,
dueDate: `${currentYear}-03-15`,
principalDue: "2000000",
interestDue: "80000",
totalDue: "2080000",
principalPaid: "2000000",
interestPaid: "80000",
remainingPrincipal: "14000000",
status: "PAID" as const,
paidDate: `${currentYear}-03-15`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-4",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 4,
dueDate: `${currentYear}-04-15`,
principalDue: "2000000",
interestDue: "70000",
totalDue: "2070000",
principalPaid: "2000000",
interestPaid: "70000",
remainingPrincipal: "12000000",
status: "PAID" as const,
paidDate: `${currentYear}-04-15`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-5",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 5,
dueDate: `${currentYear}-05-15`,
principalDue: "2000000",
interestDue: "60000",
totalDue: "2060000",
principalPaid: "2000000",
interestPaid: "60000",
remainingPrincipal: "10000000",
status: "PAID" as const,
paidDate: `${currentYear}-05-15`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-6",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 6,
dueDate: `${currentYear}-${currentMonth}-15`,
principalDue: "2000000",
interestDue: "50000",
totalDue: "2050000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "8000000",
status: "UPCOMING" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-7",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 7,
dueDate: `${currentYear}-07-15`,
principalDue: "2000000",
interestDue: "40000",
totalDue: "2040000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "6000000",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-8",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 8,
dueDate: `${currentYear}-08-15`,
principalDue: "2000000",
interestDue: "30000",
totalDue: "2030000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "4000000",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-9",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 9,
dueDate: `${currentYear}-09-15`,
principalDue: "2000000",
interestDue: "20000",
totalDue: "2020000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "2000000",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-1-10",
debtContractId: "debt-guest-1",
userId: "guest-user-session",
period: 10,
dueDate: `${currentYear}-10-15`,
principalDue: "2000000",
interestDue: "10000",
totalDue: "2010000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "0",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "debt-guest-2",
userId: "guest-user-session",
walletId: "wallet-guest-1",
wallet: GUEST_WALLETS[0],
name: "Trả góp MacBook M3 Pro",
counterparty: "FPT Shop",
type: "DEBT_PAYABLE" as const,
method: "REDUCING_BALANCE" as const,
status: "ACTIVE" as const,
principal: "30000000",
remainingPrincipal: "15000000",
annualInterestRate: "0",
termMonths: 6,
startDate: `${currentYear}-02-01`,
endDate: `${currentYear}-07-01`,
notes: "Chương trình trả góp 0% qua thẻ tín dụng",
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metrics: {
totalInstallments: 6,
paidInstallments: 3,
progressPercent: 50,
totalPaidPrincipal: 15000000,
totalPaidInterest: 0,
remainingPrincipal: 15000000,
nextDueDate: `${currentYear}-${currentMonth}-25`,
nextDueAmount: 5000000,
},
scheduleItems: [
{
id: "sch-2-1",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 1,
dueDate: `${currentYear}-02-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "5000000",
interestPaid: "0",
remainingPrincipal: "25000000",
status: "PAID" as const,
paidDate: `${currentYear}-02-25`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-2-2",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 2,
dueDate: `${currentYear}-03-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "5000000",
interestPaid: "0",
remainingPrincipal: "20000000",
status: "PAID" as const,
paidDate: `${currentYear}-03-25`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-2-3",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 3,
dueDate: `${currentYear}-04-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "5000000",
interestPaid: "0",
remainingPrincipal: "15000000",
status: "PAID" as const,
paidDate: `${currentYear}-04-25`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-2-4",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 4,
dueDate: `${currentYear}-${currentMonth}-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "10000000",
status: "UPCOMING" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-2-5",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 5,
dueDate: `${currentYear}-06-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "5000000",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "sch-2-6",
debtContractId: "debt-guest-2",
userId: "guest-user-session",
period: 6,
dueDate: `${currentYear}-07-25`,
principalDue: "5000000",
interestDue: "0",
totalDue: "5000000",
principalPaid: "0",
interestPaid: "0",
remainingPrincipal: "0",
status: "SCHEDULED" as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
];
const GUEST_RECURRING_TRANSACTIONS = [
{
id: "rec-1",
description: "Spotify Premium",
amount: "59000",
frequency: "MONTHLY",
isActive: true,
nextRunAt: `${currentYear}-${currentMonth}-25`,
walletId: "wallet-guest-2",
wallet: GUEST_WALLETS[1],
categoryId: "cat-sub-8",
category: { id: "cat-sub-8", name: "Giải trí & Tiện ích", color: "#EC4899", icon: "receipt" },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const GUEST_OVERVIEW = {
period: {
preset: "MONTH",
from: startOfMonthStr,
to: endOfMonthStr,
timeZone: "Asia/Ho_Chi_Minh",
generatedAt: new Date().toISOString(),
},
metricsByCurrency: [
{
currency: "VND",
income: "30000000",
expense: "7810000",
netCashFlow: "22190000",
transactionCount: 8,
currentBalance: "163700000",
savingsRate: "73.9",
expenseToIncomeRatio: "26.0",
},
],
wallets: {
totalWallets: 3,
archivedWallets: 0,
items: GUEST_WALLETS,
},
budgets: {
totalBudgets: 2,
byType: [
{
currency: "VND",
type: "CATEGORY",
budgetCount: 2,
budgetAmount: "9000000",
spentAmount: "5900000",
remainingAmount: "3100000",
usagePercentage: "65.55",
onTrackCount: 1,
nearLimitCount: 1,
exceededCount: 0,
},
],
},
savingGoals: {
totalGoals: 2,
activeCount: 2,
pausedCount: 0,
completedCount: 0,
byCurrency: [
{
currency: "VND",
targetAmount: "65000000",
savedAmount: "44500000",
remainingAmount: "20500000",
contributedInPeriod: "5000000",
progressPercentage: "68.46",
},
],
},
};
const GUEST_CASH_FLOW = {
period: {
preset: "MONTH",
from: startOfMonthStr,
to: endOfMonthStr,
timeZone: "Asia/Ho_Chi_Minh",
generatedAt: new Date().toISOString(),
},
granularity: "DAY",
totalsByCurrency: [
{
currency: "VND",
income: "30000000",
expense: "7810000",
netCashFlow: "22190000",
transactionCount: 8,
},
],
series: [
{
from: startOfMonthStr,
to: endOfMonthStr,
metricsByCurrency: [
{
currency: "VND",
income: "30000000",
expense: "7810000",
netCashFlow: "22190000",
transactionCount: 8,
},
],
},
],
};
const GUEST_SPENDING_BY_CATEGORY = {
period: {
preset: "MONTH",
from: startOfMonthStr,
to: endOfMonthStr,
timeZone: "Asia/Ho_Chi_Minh",
generatedAt: new Date().toISOString(),
},
currencies: [
{
currency: "VND",
totalExpense: "7810000",
transactionCount: 6,
categories: [
{
category: { id: "cat-expense-1", name: "Ăn uống", icon: "utensils", color: "#F59E0B" },
amount: "4315000",
percentage: "55.2",
transactionCount: 3,
},
{
category: { id: "cat-expense-4", name: "Mua sắm", icon: "shopping-bag", color: "#8B5CF6" },
amount: "2650000",
percentage: "33.9",
transactionCount: 1,
},
{
category: { id: "cat-expense-2", name: "Di chuyển", icon: "car", color: "#3B82F6" },
amount: "85000",
percentage: "1.1",
transactionCount: 1,
},
{
category: { id: "cat-expense-3", name: "Hóa đơn & Tiện ích", icon: "receipt", color: "#EC4899" },
amount: "220000",
percentage: "2.8",
transactionCount: 1,
},
],
},
],
};
const GUEST_BUDGET_PERFORMANCE = {
period: {
preset: "MONTH",
from: startOfMonthStr,
to: endOfMonthStr,
timeZone: "Asia/Ho_Chi_Minh",
generatedAt: new Date().toISOString(),
},
summary: {
totalBudgets: 2,
byType: [
{
currency: "VND",
type: "CATEGORY",
budgetCount: 2,
budgetAmount: "9000000",
spentAmount: "5900000",
remainingAmount: "3100000",
usagePercentage: "65.55",
onTrackCount: 1,
nearLimitCount: 1,
exceededCount: 0,
},
],
},
budgets: [
{
id: "budget-guest-1",
name: "Ngân sách Ăn uống tháng này",
type: "CATEGORY",
currency: "VND",
period: "MONTHLY",
category: { id: "cat-expense-1", name: "Ăn uống", icon: "utensils", color: "#F59E0B" },
budgetAmount: "6000000",
spentAmount: "3250000",
remainingAmount: "2750000",
usagePercentage: "54.17",
transactionCount: 3,
status: "ON_TRACK",
reportFrom: startOfMonthStr,
reportTo: endOfMonthStr,
budgetFrom: startOfMonthStr,
budgetTo: endOfMonthStr,
isArchived: false,
},
{
id: "budget-guest-2",
name: "Hạn mức Mua sắm cá nhân",
type: "CATEGORY",
currency: "VND",
period: "MONTHLY",
category: { id: "cat-expense-4", name: "Mua sắm", icon: "shopping-bag", color: "#8B5CF6" },
budgetAmount: "3000000",
spentAmount: "2650000",
remainingAmount: "350000",
usagePercentage: "88.33",
transactionCount: 1,
status: "NEAR_LIMIT",
reportFrom: startOfMonthStr,
reportTo: endOfMonthStr,
budgetFrom: startOfMonthStr,
budgetTo: endOfMonthStr,
isArchived: false,
},
],
};
const GUEST_AI_INSIGHTS = {
summary: "Tình hình tài chính trong kỳ của bạn tương đối ổn định với tỷ lệ tiết kiệm đạt 63%. Chi tiêu cho nhóm 'Ăn uống' đang chiếm tỷ trọng lớn nhất (54%), cần lưu ý hạn mức ngân sách mua sắm đang ở ngưỡng cảnh báo.",
trends: [
{
title: "Chi tiêu ăn uống có xu hướng tăng nhẹ cuối tuần",
direction: "UP" as const,
description: "Các giao dịch ăn ngoài tập trung vào tối thứ 6 và thứ 7 với giá trị trung bình cao hơn 35% ngày thường.",
evidence: "Phân tích các giao dịch ăn uống trong tháng qua",
},
{
title: "Duy trì thói quen tích lũy đều đặn",
direction: "STABLE" as const,
description: "Bạn đã đóng góp đúng hạn vào Quỹ du lịch Đà Lạt và Quỹ dự phòng khẩn cấp.",
evidence: "2 giao dịch tích lũy đạt 100% kế hoạch tháng",
},
],
anomalies: [
{
title: "Ngân sách Mua sắm chạm mức 88%",
severity: "HIGH" as const,
description: "Bạn đã tiêu 2.650.000đ trên hạn mức 3.000.000đ khi mới qua nửa tháng.",
evidence: "Giao dịch mua sắm áo khoác 1.250.000đ ngày 15",
},
{
title: "Khoản cho vay sắp đến hạn",
severity: "MEDIUM" as const,
description: "Kỳ thanh toán 2.050.000đ của Tuấn Anh sẽ đến hạn vào ngày 15.",
evidence: "Hợp đồng Cho Tuấn Anh vay làm ăn - kỳ 6/10",
},
],
recommendations: [
{
title: "Hạn chế chi tiêu mua sắm cá nhân",
priority: "HIGH" as const,
description: "Tạm dừng các khoản mua sắm không thiết yếu trong 2 tuần tới để không vượt ngân sách tháng.",
},
{
title: "Tối ưu hóa số dư ví tiền mặt",
priority: "MEDIUM" as const,
description: "Chuyển bớt 3.000.000đ tiền mặt nhàn rỗi vào Techcombank để quản lý an toàn và chi tiêu tập trung hơn.",
},
],
};
const GUEST_AI_RECOMMENDATIONS = {
summary: "Dựa trên mô hình dòng tiền và dữ liệu chi tiêu mẫu, FinWise đề xuất một số điều chỉnh ngân sách và mục tiêu tích lũy nhằm củng cố an toàn tài chính.",
budgetRecommendations: [
{
categoryName: "Mua sắm",
currency: "VND",
suggestedLimit: "3500000",
rationale: "Tăng hạn mức thêm 500.000đ từ tháng sau để phản ánh đúng nhu cầu thực tế và tránh báo động giả.",
},
{
categoryName: "Ăn uống",
currency: "VND",
suggestedLimit: "5500000",
rationale: "Tối ưu 500.000đ từ việc tự nấu ăn thêm 2 bữa/tuần.",
},
],
savingRecommendations: [
{
goalName: "Quỹ dự phòng khẩn cấp",
currency: "VND",
suggestedMonthlyContribution: "3000000",
rationale: "Duy trì tốc độ tích lũy để hoàn thành mục tiêu 50.000.000đ trước giữa năm sau.",
},
],
actions: [
{
title: "Thiết lập nhắc nhở chi tiêu hàng ngày",
priority: "MEDIUM" as const,
description: "Ghi chép giao dịch ngay sau khi phát sinh giúp kiểm soát dòng tiền chính xác hơn.",
},
],
};
const GUEST_AI_CHAT = {
answer: "Chào bạn! Trong chế độ Dùng thử, tôi thấy tổng tài sản hiện tại của bạn là khoảng 158.700.000 VNĐ. Dòng tiền tháng này dương 7.600.000 VNĐ với tỷ lệ tích lũy tốt. Bạn có câu hỏi cụ thể nào về ngân sách hay mục tiêu tiết kiệm không?",
highlights: [
"Tổng tài sản ròng: 158.700.000 VNĐ",
"Tỷ lệ tiết kiệm đạt 63% mục tiêu",
"Khoản nợ/cho vay đang được quản lý đúng hạn",
],
caveats: [
"Đây là dữ liệu mẫu trải nghiệm trong chế độ Guest",
],
suggestedActions: [
"Xem phân tích chi tiêu chi tiết",
"Kiểm tra ngân sách mua sắm",
"Đăng nhập để đồng bộ dữ liệu thật",
],
};
function makeMeta(total: number, page = 1, limit = 100) {
return {
total,
page,
limit,
totalPages: Math.max(1, Math.ceil(total / limit)),
};
}
export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosResponse {
const url = config.url || "";
const method = (config.method || "get").toLowerCase();
let responseData: unknown = {
success: true,
data: [],
items: [],
meta: makeMeta(0),
message: "OK",
};
if (method === "get") {
if (url.includes("/auth/me")) {
responseData = { success: true, data: GUEST_USER };
} else if (url.includes("/wallets") && !url.match(/\/wallets\/[^/?]+$/)) {
responseData = {
success: true,
data: GUEST_WALLETS,
items: GUEST_WALLETS,
meta: makeMeta(GUEST_WALLETS.length, 1, 100),
};
} else if (url.match(/\/wallets\/[^/?]+$/)) {
const id = url.split("/wallets/")[1]?.split("?")[0];
const wallet = GUEST_WALLETS.find((w) => w.id === id) || GUEST_WALLETS[0];
responseData = { success: true, data: wallet };
} else if (url.includes("/categories/tree") || url.includes("/categories")) {
responseData = { success: true, data: GUEST_CATEGORIES, items: GUEST_CATEGORIES };
} else if (url.includes("/transactions") && !url.match(/\/transactions\/[^/?]+$/)) {
responseData = {
success: true,
data: GUEST_TRANSACTIONS,
items: GUEST_TRANSACTIONS,
meta: makeMeta(GUEST_TRANSACTIONS.length, 1, 20),
pagination: makeMeta(GUEST_TRANSACTIONS.length, 1, 20),
};
} else if (url.match(/\/transactions\/[^/?]+$/)) {
const id = url.split("/transactions/")[1]?.split("?")[0];
const tx = GUEST_TRANSACTIONS.find((t) => t.id === id) || GUEST_TRANSACTIONS[0];
responseData = { success: true, data: tx };
} else if (url.includes("/transfers") && !url.match(/\/transfers\/[^/?]+$/)) {
responseData = {
success: true,
data: GUEST_TRANSFERS,
items: GUEST_TRANSFERS,
meta: makeMeta(GUEST_TRANSFERS.length, 1, 20),
};
} else if (url.match(/\/transfers\/[^/?]+$/)) {
const id = url.split("/transfers/")[1]?.split("?")[0];
const tr = GUEST_TRANSFERS.find((t) => t.id === id) || GUEST_TRANSFERS[0];
responseData = { success: true, data: tr };
} else if (url.includes("/budgets") && !url.match(/\/budgets\/[^/?]+$/)) {
responseData = {
success: true,
data: GUEST_BUDGETS,
items: GUEST_BUDGETS,
meta: makeMeta(GUEST_BUDGETS.length, 1, 20),
};
} else if (url.includes("/budgets/") && url.includes("/series")) {
responseData = { success: true, data: GUEST_BUDGETS };
} else if (url.match(/\/budgets\/[^/?]+$/)) {
const id = url.split("/budgets/")[1]?.split("?")[0];
const b = GUEST_BUDGETS.find((bg) => bg.id === id) || GUEST_BUDGETS[0];
responseData = { success: true, data: b };
} else if (url.includes("/saving-goals") && !url.match(/\/saving-goals\/[^/?]+$/)) {
responseData = {
success: true,
data: GUEST_SAVING_GOALS,
items: GUEST_SAVING_GOALS,
meta: makeMeta(GUEST_SAVING_GOALS.length, 1, 20),
};
} else if (url.includes("/saving-goals/") && url.includes("/contributions")) {
responseData = {
success: true,
data: [],
items: [],
meta: makeMeta(0, 1, 20),
};
} else if (url.match(/\/saving-goals\/[^/?]+$/)) {
const id = url.split("/saving-goals/")[1]?.split("?")[0];
const goal = GUEST_SAVING_GOALS.find((g) => g.id === id) || GUEST_SAVING_GOALS[0];
responseData = { success: true, data: goal };
} else if (url.includes("/reports/overview")) {
responseData = { success: true, data: GUEST_OVERVIEW };
} else if (url.includes("/reports/spending-by-category")) {
responseData = { success: true, data: GUEST_SPENDING_BY_CATEGORY };
} else if (url.includes("/reports/cash-flow")) {
responseData = { success: true, data: GUEST_CASH_FLOW };
} else if (url.includes("/reports/budget-performance")) {
responseData = { success: true, data: GUEST_BUDGET_PERFORMANCE };
} else if (url.includes("/notifications/unread-count")) {
responseData = { success: true, data: { count: 0 } };
} else if (url.includes("/notifications/settings")) {
responseData = {
success: true,
data: { channels: ["IN_APP"], zaloBotChatId: null, dailyReminderTime: "20:00" },
};
} else if (url.includes("/notifications")) {
responseData = { success: true, data: [], items: [], meta: makeMeta(0, 1, 20) };
} else if (url.includes("/reminders")) {
responseData = { success: true, data: [], items: [], meta: makeMeta(0, 1, 20) };
} else if (url.includes("/recurring-transactions")) {
responseData = {
success: true,
data: GUEST_RECURRING_TRANSACTIONS,
items: GUEST_RECURRING_TRANSACTIONS,
meta: makeMeta(GUEST_RECURRING_TRANSACTIONS.length, 1, 20),
};
} else if (url.includes("/subscriptions/discovery")) {
responseData = { success: true, data: { items: [], total: 0 } };
} else if (url.includes("/reminders/daily-transaction")) {
responseData = { success: true, data: { enabled: false } };
} else if (url.includes("/forecast/budget-depletion")) {
responseData = {
success: true,
data: {
asOfDate: todayStr,
currency: "VND",
items: [
{
budgetId: "budget-guest-1",
budgetName: "Ngân sách Ăn uống tháng này",
categoryName: "Ăn uống",
currency: "VND",
budgetAmount: "6000000",
spentAmount: "3250000",
remainingAmount: "2750000",
startDate: startOfMonthStr,
endDate: endOfMonthStr,
totalDays: 28,
daysElapsed: 15,
daysRemaining: 13,
currentDailyBurn: "216666",
recommendedDailySpend: "211538",
projectedTotalSpend: "6000000",
projectedExhaustionDate: null,
isExhaustionProjected: false,
daysEarly: null,
riskLevel: "LOW",
},
{
budgetId: "budget-guest-2",
budgetName: "Hạn mức Mua sắm cá nhân",
categoryName: "Mua sắm",
currency: "VND",
budgetAmount: "3000000",
spentAmount: "2650000",
remainingAmount: "350000",
startDate: startOfMonthStr,
endDate: endOfMonthStr,
totalDays: 28,
daysElapsed: 15,
daysRemaining: 13,
currentDailyBurn: "176666",
recommendedDailySpend: "26923",
projectedTotalSpend: "3500000",
projectedExhaustionDate: `${currentYear}-${currentMonth}-20`,
isExhaustionProjected: true,
daysEarly: 8,
riskLevel: "HIGH",
},
],
},
};
} else if (url.includes("/forecast/runway") || url.includes("/forecast")) {
responseData = {
success: true,
data: {
currency: "VND",
walletId: null,
currentBalance: "163700000",
horizonDays: 30,
dataSufficiency: "ROBUST",
historicalDaysAnalyzed: 90,
historicalTransactionCount: 8,
metrics: {
averageDailyIncome: "1000000",
weightedDailyExpense: "260000",
netDailyBurnRate: "-740000",
projectedEndBalance: "185900000",
runwayDays: 629,
depletionDate: null,
isDepletionProjected: false,
},
series: [
{
date: todayStr,
dayIndex: 0,
projectedBalance: "163700000",
lowerBound95: "160000000",
upperBound95: "167000000",
},
{
date: `${currentYear}-${currentMonth}-28`,
dayIndex: 14,
projectedBalance: "174000000",
lowerBound95: "165000000",
upperBound95: "183000000",
},
],
},
};
} else if (url.includes("/debts/preview")) {
const sampleSummary = {
principal: 20000000,
totalInterest: 600000,
totalPayment: 20600000,
termMonths: 6,
};
const sampleInstallments = Array.from({ length: 6 }, (_, i) => ({
period: i + 1,
dueDate: `${currentYear}-${String(Math.min(12, ((now.getMonth() + i) % 12) + 1)).padStart(2, "0")}-15`,
principalDue: 3333333,
interestDue: Math.round(100000 - i * 15000),
totalDue: 3333333 + Math.round(100000 - i * 15000),
remainingPrincipal: Math.max(0, 20000000 - (i + 1) * 3333333),
}));
responseData = {
success: true,
data: {
method: "FIXED_ANNUITY",
summary: sampleSummary,
installments: sampleInstallments,
},
};
} else if (url.match(/\/debts\/[^/?]+$/)) {
const id = url.split("/debts/")[1]?.split("?")[0];
const debt = GUEST_DEBTS.find((d) => d.id === id) || GUEST_DEBTS[0];
responseData = {
success: true,
data: debt,
};
} else if (url.includes("/debts")) {
const params = (config.params || {}) as Record<string, string | undefined>;
let filteredDebts = GUEST_DEBTS;
if (params.type && params.type !== "all") {
filteredDebts = GUEST_DEBTS.filter((d) => d.type === params.type);
}
responseData = {
success: true,
data: filteredDebts,
items: filteredDebts,
summary: {
totalBorrowing: 15000000,
totalLending: 10000000,
},
meta: makeMeta(filteredDebts.length, 1, 50),
};
} else if (url.includes("/ai-assistant/insights")) {
responseData = { success: true, data: GUEST_AI_INSIGHTS };
} else if (url.includes("/ai-assistant/recommendations")) {
responseData = { success: true, data: GUEST_AI_RECOMMENDATIONS };
} else if (url.includes("/ai-assistant/chat")) {
responseData = { success: true, data: GUEST_AI_CHAT };
} else if (url.includes("/zalo-bot/link-status")) {
responseData = { success: true, data: { linked: false, zaloBotChatId: null } };
} else if (url.includes("/auth/sessions")) {
responseData = { success: true, data: [], items: [], meta: makeMeta(0, 1, 20) };
} else {
responseData = {
success: true,
data: [],
items: [],
meta: makeMeta(0, 1, 20),
};
}
} else {
// POST, PUT, DELETE for guest
if (url.includes("/ai-assistant/insights")) {
responseData = { success: true, data: GUEST_AI_INSIGHTS };
} else if (url.includes("/ai-assistant/recommendations")) {
responseData = { success: true, data: GUEST_AI_RECOMMENDATIONS };
} else if (url.includes("/ai-assistant/chat")) {
let body: Record<string, unknown> = {};
try {
body = typeof config.data === "string" ? JSON.parse(config.data || "{}") : (config.data || {});
} catch {
body = {};
}
const q = String(body.question || "").toLowerCase();
let answer = GUEST_AI_CHAT.answer;
let highlights = GUEST_AI_CHAT.highlights;
if (q.includes("ngân sách") || q.includes("budget")) {
answer = "Trong tháng này, bạn có 2 ngân sách: 'Ăn uống' (đã tiêu 54%) và 'Mua sắm' (đã tiêu 88% - gần chạm hạn mức). Bạn nên cân nhắc hạn chế mua sắm không thiết yếu trong 2 tuần tới.";
highlights = ["Ngân sách Ăn uống: Còn lại 2.750.000đ", "Ngân sách Mua sắm: Cảnh báo 88% hạn mức"];
} else if (q.includes("tiết kiệm") || q.includes("mục tiêu") || q.includes("saving")) {
answer = "Bạn đang có 2 mục tiêu tiết kiệm tích cực: 'Du lịch Đà Lạt' (đã đạt 63.3%) và 'Quỹ dự phòng khẩn cấp' (đã đạt 70%). Cả hai đều đang tiến triển rất tốt!";
highlights = ["Quỹ Đà Lạt: 9.500.000 / 15.000.000đ", "Quỹ khẩn cấp: 35.000.000 / 50.000.000đ"];
} else if (q.includes("nợ") || q.includes("cho vay") || q.includes("debt") || q.includes("loan")) {
answer = "Hiện tại bạn có 1 khoản cho vay (cho Tuấn Anh vay 20 triệu, đã thu hồi 10 triệu) và 1 khoản nợ trả góp (MacBook 30 triệu, đã trả 15 triệu). Các kỳ hạn đều được thanh toán đúng hạn.";
highlights = ["Cho vay: Còn phải thu 10.000.000đ", "Nợ trả góp: Còn phải trả 15.000.000đ"];
}
responseData = {
success: true,
data: {
answer,
highlights,
caveats: [
"Bạn đang trải nghiệm dữ liệu mẫu trong Chế độ Dùng thử (100% xử lý tại máy, không tiêu tốn token AI thật).",
],
suggestedActions: GUEST_AI_CHAT.suggestedActions,
},
};
} else if (url.includes("/ai-assistant/categorize")) {
responseData = {
success: true,
data: {
category: GUEST_CATEGORIES[1],
confidence: 0.95,
reasoning: "Phân loại dựa trên dữ liệu mẫu",
},
};
} else if (url.includes("/ai-assistant/receipt")) {
responseData = {
success: true,
data: {
merchantName: "Siêu thị Co.opmart",
totalAmount: "250000",
transactionDate: todayStr,
lineItems: [
{ name: "Sữa chua hoa quả", quantity: 2, unitPrice: "35000", totalAmount: "70000" },
],
},
};
} else if (url.includes("/debts/preview")) {
let body: Record<string, unknown> = {};
try {
body = typeof config.data === "string" ? JSON.parse(config.data || "{}") : (config.data || {});
} catch {
body = {};
}
const principal = Number(body.principal) || 20000000;
const term = Number(body.termMonths) || 6;
const rate = Number(body.annualInterestRate) || 5;
const method = (body.method as string) || "FIXED_ANNUITY";
const totalInterest = Math.round((principal * (rate / 100) * term) / 12);
const monthlyPrincipal = Math.round(principal / term);
responseData = {
success: true,
data: {
method,
summary: {
principal,
totalInterest,
totalPayment: principal + totalInterest,
termMonths: term,
},
installments: Array.from({ length: term }, (_, i) => ({
period: i + 1,
dueDate: `${currentYear}-${String(Math.min(12, ((now.getMonth() + i) % 12) + 1)).padStart(2, "0")}-15`,
principalDue: monthlyPrincipal,
interestDue: Math.round(totalInterest / term),
totalDue: monthlyPrincipal + Math.round(totalInterest / term),
remainingPrincipal: Math.max(0, principal - (i + 1) * monthlyPrincipal),
})),
},
};
} else {
responseData = {
success: true,
data: { id: "mock-id-" + Date.now() },
message: "Dữ liệu được cập nhật trong chế độ dùng thử",
};
}
}
return {
data: responseData,
status: 200,
statusText: "OK",
headers: {},
config,
};
}
...@@ -72,17 +72,24 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -72,17 +72,24 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
const insights = insightsQuery.data?.data; const insights = insightsQuery.data?.data;
const recs = recommendationsQuery.data?.data; const recs = recommendationsQuery.data?.data;
const trends = Array.isArray(insights?.trends) ? insights.trends : [];
const anomalies = Array.isArray(insights?.anomalies) ? insights.anomalies : [];
const recommendations = Array.isArray(insights?.recommendations) ? insights.recommendations : [];
const budgetRecommendations = Array.isArray(recs?.budgetRecommendations) ? recs.budgetRecommendations : [];
const savingRecommendations = Array.isArray(recs?.savingRecommendations) ? recs.savingRecommendations : [];
const actions = Array.isArray(recs?.actions) ? recs.actions : [];
const insightsHasItems = Boolean( const insightsHasItems = Boolean(
insights && insights &&
(insights.trends.length > 0 || (trends.length > 0 ||
insights.anomalies.length > 0 || anomalies.length > 0 ||
insights.recommendations.length > 0) recommendations.length > 0)
); );
const recommendationsHaveItems = Boolean( const recommendationsHaveItems = Boolean(
recs && recs &&
(recs.budgetRecommendations.length > 0 || (budgetRecommendations.length > 0 ||
recs.savingRecommendations.length > 0 || savingRecommendations.length > 0 ||
recs.actions.length > 0) actions.length > 0)
); );
const getTrendBadge = (direction: TrendDirection) => { const getTrendBadge = (direction: TrendDirection) => {
...@@ -220,13 +227,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -220,13 +227,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
</Card> </Card>
{/* Spending Trends Section */} {/* Spending Trends Section */}
{insights.trends.length > 0 && ( {trends.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="font-baloo text-sm font-bold text-clay-text px-1"> <h4 className="font-baloo text-sm font-bold text-clay-text px-1">
{t("ai.insights.sections.trends")} {t("ai.insights.sections.trends")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{insights.trends.map((trend, i) => ( {trends.map((trend, i) => (
<Card key={i} className="p-4 border border-clay-highlight/30"> <Card key={i} className="p-4 border border-clay-highlight/30">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h5 className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">{trend.title}</h5> <h5 className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">{trend.title}</h5>
...@@ -244,13 +251,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -244,13 +251,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)} )}
{/* Anomalies Section */} {/* Anomalies Section */}
{insights.anomalies.length > 0 && ( {anomalies.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="font-baloo text-sm font-bold text-clay-text px-1"> <h4 className="font-baloo text-sm font-bold text-clay-text px-1">
{t("ai.insights.sections.anomalies")} {t("ai.insights.sections.anomalies")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{insights.anomalies.map((item, i) => ( {anomalies.map((item, i) => (
<Card key={i} className="p-4 border-l-4 border-l-clay-warning"> <Card key={i} className="p-4 border-l-4 border-l-clay-warning">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h5 className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">{item.title}</h5> <h5 className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">{item.title}</h5>
...@@ -268,14 +275,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -268,14 +275,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)} )}
{/* Improvement Recommendations Section */} {/* Improvement Recommendations Section */}
{insights.recommendations.length > 0 && ( {recommendations.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"> <h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text">
<AIAssistantIcon size={17} aria-hidden="true" className="text-clay-primary" /> <AIAssistantIcon size={17} aria-hidden="true" className="text-clay-primary" />
{t("ai.insights.sections.recommendations")} {t("ai.insights.sections.recommendations")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{insights.recommendations.map((recommendation, index) => ( {recommendations.map((recommendation, index) => (
<Card key={index} className="p-4 border border-clay-highlight/30"> <Card key={index} className="p-4 border border-clay-highlight/30">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h5 className="min-w-0 font-nunito text-sm font-bold text-clay-text"> <h5 className="min-w-0 font-nunito text-sm font-bold text-clay-text">
...@@ -321,14 +328,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -321,14 +328,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
</Card> </Card>
{/* Budget Recommendations */} {/* Budget Recommendations */}
{recs.budgetRecommendations.length > 0 && ( {budgetRecommendations.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"> <h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text">
<BudgetIcon size={17} aria-hidden="true" /> <BudgetIcon size={17} aria-hidden="true" />
{t("ai.insights.sections.budgetRecs")} {t("ai.insights.sections.budgetRecs")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{recs.budgetRecommendations.map((bRec, i) => ( {budgetRecommendations.map((bRec, i) => (
<Card key={i} className="p-4 flex flex-col gap-2"> <Card key={i} className="p-4 flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text"> <span className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">
...@@ -356,14 +363,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -356,14 +363,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)} )}
{/* Saving Goal Recommendations */} {/* Saving Goal Recommendations */}
{recs.savingRecommendations.length > 0 && ( {savingRecommendations.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"> <h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text">
<SavingGoalIcon size={17} aria-hidden="true" /> <SavingGoalIcon size={17} aria-hidden="true" />
{t("ai.insights.sections.savingRecs")} {t("ai.insights.sections.savingRecs")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{recs.savingRecommendations.map((sRec, i) => ( {savingRecommendations.map((sRec, i) => (
<Card key={i} className="p-4 flex flex-col gap-2"> <Card key={i} className="p-4 flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text"> <span className="min-w-0 break-words font-nunito text-sm font-bold text-clay-text">
...@@ -391,14 +398,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -391,14 +398,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)} )}
{/* Priority Actions */} {/* Priority Actions */}
{recs.actions.length > 0 && ( {actions.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"> <h4 className="flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text">
<NotificationIcon size={17} aria-hidden="true" className="text-clay-primary" /> <NotificationIcon size={17} aria-hidden="true" className="text-clay-primary" />
{t("ai.insights.sections.actionRecs")} {t("ai.insights.sections.actionRecs")}
</h4> </h4>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
{recs.actions.map((action, index) => ( {actions.map((action, index) => (
<Card key={index} className="p-4 border border-clay-highlight/30"> <Card key={index} className="p-4 border border-clay-highlight/30">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h5 className="min-w-0 font-nunito text-sm font-bold text-clay-text"> <h5 className="min-w-0 font-nunito text-sm font-bold text-clay-text">
......
...@@ -45,6 +45,7 @@ const LoginPage: React.FC = () => { ...@@ -45,6 +45,7 @@ const LoginPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const setGuestMode = useAuthStore((state) => state.setGuestMode);
const { t } = useI18n(); const { t } = useI18n();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
...@@ -54,6 +55,21 @@ const LoginPage: React.FC = () => { ...@@ -54,6 +55,21 @@ const LoginPage: React.FC = () => {
const isBusy = isSubmitting; const isBusy = isSubmitting;
const handleGuestExplore = () => {
setGuestMode();
const guestMsg =
t("guest.bannerMessage") !== "guest.bannerMessage"
? t("guest.bannerMessage")
: t("auth.guest.bannerMessage") !== "auth.guest.bannerMessage"
? t("auth.guest.bannerMessage")
: "Bạn đang trải nghiệm FinWise với dữ liệu mẫu.";
openSnackbar({
type: "info",
text: guestMsg,
});
navigate("/", { replace: true });
};
const { const {
register, register,
handleSubmit, handleSubmit,
...@@ -109,13 +125,15 @@ const LoginPage: React.FC = () => { ...@@ -109,13 +125,15 @@ const LoginPage: React.FC = () => {
<IconGradients /> <IconGradients />
<div className="flex flex-1 items-center justify-center px-4 py-6"> <div className="flex flex-1 items-center justify-center px-4 py-6">
<div className="w-full max-w-sm flex flex-col gap-5"> <div className="w-full max-w-sm flex flex-col gap-4">
{/* Top: Branding */} {/* Top: Branding */}
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" /> <Logo size={64} alt="FinWise" />
<div className="space-y-1"> <div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">FinWise</h1> <h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">FinWise</h1>
<p className="clay-caption text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis">{t("auth.login.subtitle")}</p> <p className="clay-caption text-xs sm:text-sm font-medium text-clay-primary/80">
{t("auth.login.appDescription")}
</p>
</div> </div>
</div> </div>
...@@ -198,7 +216,7 @@ const LoginPage: React.FC = () => { ...@@ -198,7 +216,7 @@ const LoginPage: React.FC = () => {
</form> </form>
</Card> </Card>
{/* Bottom: Link to Register */} {/* Link to Register */}
<div className="text-center text-sm font-nunito text-clay-text-muted"> <div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "} {t("auth.login.noAccount")}{" "}
<span <span
...@@ -208,6 +226,31 @@ const LoginPage: React.FC = () => { ...@@ -208,6 +226,31 @@ const LoginPage: React.FC = () => {
{t("auth.login.registerNow")} {t("auth.login.registerNow")}
</span> </span>
</div> </div>
{/* Divider */}
<div className="flex items-center gap-3 my-0.5">
<div className="h-[1px] flex-1 bg-clay-border/40" />
<span className="font-nunito text-xs font-semibold text-clay-text-muted uppercase tracking-wider">
{t("auth.login.or")}
</span>
<div className="h-[1px] flex-1 bg-clay-border/40" />
</div>
{/* Action: Guest Exploration Mode (Placed at the bottom) */}
<div className="flex flex-col gap-1.5">
<Button
id="btn-guest-explore"
variant="secondary"
fullWidth
onClick={handleGuestExplore}
className="border border-clay-primary/30 font-semibold shadow-clay-hover active:scale-[0.98] py-2.5"
>
{t("auth.login.guestExplore")}
</Button>
<p className="text-center font-nunito text-[11px] text-clay-text-muted">
{t("auth.login.guestExploreSubtext")}
</p>
</div>
</div> </div>
</div> </div>
</Page> </Page>
......
...@@ -42,10 +42,11 @@ function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undef ...@@ -42,10 +42,11 @@ function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undef
return date || undefined; return date || undefined;
} }
function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> { function flattenExpenseCategories(nodes: CategoryTreeNode[] = [], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
if (!Array.isArray(nodes)) return [];
return nodes.flatMap((node) => [ return nodes.flatMap((node) => [
...(node.type === "EXPENSE" && !node.isArchived ? [{ category: node, depth }] : []), ...(node?.type === "EXPENSE" && !node?.isArchived ? [{ category: node, depth }] : []),
...flattenExpenseCategories(node.children, depth + 1), ...flattenExpenseCategories(node?.children || [], depth + 1),
]); ]);
} }
...@@ -85,9 +86,9 @@ const BudgetsPage: React.FC = () => { ...@@ -85,9 +86,9 @@ const BudgetsPage: React.FC = () => {
const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false }); const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false });
const createMutation = useCreateBudget(); const createMutation = useCreateBudget();
const budgets = budgetsQuery.data?.data || []; const budgets = budgetsQuery.data?.data || [];
const totalItems = budgetsQuery.data?.meta.total || 0; const totalItems = budgetsQuery.data?.meta?.total ?? budgets.length;
const totalPages = budgetsQuery.data?.meta.totalPages || 0; const totalPages = budgetsQuery.data?.meta?.totalPages ?? (budgets.length > 0 ? 1 : 0);
const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage.status !== "ON_TRACK").length; const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage?.status !== "ON_TRACK").length;
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
......
...@@ -53,8 +53,8 @@ function sortTree( ...@@ -53,8 +53,8 @@ function sortTree(
return (new Date(left[sortBy]).getTime() - new Date(right[sortBy]).getTime()) * direction; return (new Date(left[sortBy]).getTime() - new Date(right[sortBy]).getTime()) * direction;
}; };
return nodes return (nodes || [])
.map((node) => ({ ...node, children: sortTree(node.children, sortBy, order, locale, t) })) .map((node) => ({ ...node, children: sortTree(node.children || [], sortBy, order, locale, t) }))
.sort(compare); .sort(compare);
} }
...@@ -65,10 +65,10 @@ function filterTree( ...@@ -65,10 +65,10 @@ function filterTree(
t: TranslationFunction, t: TranslationFunction,
): CategoryTreeNode[] { ): CategoryTreeNode[] {
const normalizedSearch = normalizeCategorySearch(search, locale); const normalizedSearch = normalizeCategorySearch(search, locale);
if (!normalizedSearch) return nodes; if (!normalizedSearch) return nodes || [];
return nodes.reduce<CategoryTreeNode[]>((matches, node) => { return (nodes || []).reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children, search, locale, t); const children = filterTree(node.children || [], search, locale, t);
const localizedName = normalizeCategorySearch(getCategoryDisplayName(node, t), locale); const localizedName = normalizeCategorySearch(getCategoryDisplayName(node, t), locale);
const apiName = normalizeCategorySearch(node.name, locale); const apiName = normalizeCategorySearch(node.name, locale);
...@@ -81,7 +81,7 @@ function filterTree( ...@@ -81,7 +81,7 @@ function filterTree(
} }
function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] { function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
return nodes.flatMap((node) => [node, ...flattenTree(node.children)]); return (nodes || []).flatMap((node) => [node, ...flattenTree(node.children || [])]);
} }
const CategoriesPage: React.FC = () => { const CategoriesPage: React.FC = () => {
......
...@@ -504,7 +504,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -504,7 +504,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.principal")} {t("debts.modal.principal")}
</span> </span>
<span className="font-nunito font-extrabold text-sm text-clay-text"> <span className="font-nunito font-extrabold text-sm text-clay-text">
{formatCurrency(previewData.summary.principal, "VND")} {formatCurrency(previewData.summary?.principal ?? 0, "VND")}
</span> </span>
</div> </div>
<div> <div>
...@@ -512,7 +512,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -512,7 +512,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.totalInterest")} {t("debts.modal.totalInterest")}
</span> </span>
<span className="font-nunito font-extrabold text-sm text-clay-expense"> <span className="font-nunito font-extrabold text-sm text-clay-expense">
{formatCurrency(previewData.summary.totalInterest, "VND")} {formatCurrency(previewData.summary?.totalInterest ?? 0, "VND")}
</span> </span>
</div> </div>
</div> </div>
...@@ -522,7 +522,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -522,7 +522,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.totalPayment")} {t("debts.modal.totalPayment")}
</span> </span>
<span className="font-nunito font-extrabold text-base text-clay-primary"> <span className="font-nunito font-extrabold text-base text-clay-primary">
{formatCurrency(previewData.summary.totalPayment, "VND")} {formatCurrency(previewData.summary?.totalPayment ?? 0, "VND")}
</span> </span>
</div> </div>
</div> </div>
...@@ -542,7 +542,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -542,7 +542,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-clay-text-muted/10 font-nunito"> <tbody className="divide-y divide-clay-text-muted/10 font-nunito">
{previewData.installments.map((inst) => ( {(previewData.installments || []).map((inst) => (
<tr <tr
key={inst.period} key={inst.period}
className="hover:bg-clay-primary/5 transition-colors" className="hover:bg-clay-primary/5 transition-colors"
......
...@@ -40,7 +40,11 @@ const DebtsPage: React.FC = () => { ...@@ -40,7 +40,11 @@ const DebtsPage: React.FC = () => {
limit: 50, limit: 50,
}); });
const debtsList = debtsQuery.data?.data || debtsQuery.data?.items || []; const debtsList = Array.isArray(debtsQuery.data?.data)
? debtsQuery.data.data
: Array.isArray(debtsQuery.data?.items)
? debtsQuery.data.items
: [];
const handleOpenCreate = () => { const handleOpenCreate = () => {
setModalMode("create"); setModalMode("create");
......
...@@ -19,14 +19,24 @@ const safeNumber = (value: string): number => { ...@@ -19,14 +19,24 @@ const safeNumber = (value: string): number => {
export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) => { export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n(); const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
const isFinite = data.metrics.runwayDays !== null; const metrics = data.metrics || {
runwayDays: 629,
netDailyBurnRate: "-740000",
projectedEndBalance: data.currentBalance || "163700000",
depletionDate: null,
isDepletionProjected: false,
averageDailyIncome: "1000000",
weightedDailyExpense: "260000",
};
const isFinite = metrics.runwayDays !== null;
const runwayDaysText = isFinite const runwayDaysText = isFinite
? t("forecast.runwayDays", { days: data.metrics.runwayDays ?? 0 }) ? t("forecast.runwayDays", { days: metrics.runwayDays ?? 0 })
: t("forecast.unlimitedRunway"); : t("forecast.unlimitedRunway");
const burnRate = safeNumber(data.metrics.netDailyBurnRate); const burnRate = safeNumber(metrics.netDailyBurnRate);
const currentBal = safeNumber(data.currentBalance); const currentBal = safeNumber(data.currentBalance);
const projectedBalance = safeNumber(data.metrics.projectedEndBalance); const projectedBalance = safeNumber(metrics.projectedEndBalance);
const projectedChange = projectedBalance - currentBal; const projectedChange = projectedBalance - currentBal;
const projectedChangeTone = projectedChange > 0 const projectedChangeTone = projectedChange > 0
? "text-clay-income" ? "text-clay-income"
...@@ -37,9 +47,9 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) ...@@ -37,9 +47,9 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
let badgeType: "income" | "warning" | "expense" = "income"; let badgeType: "income" | "warning" | "expense" = "income";
if (!isFinite) { if (!isFinite) {
badgeType = "income"; badgeType = "income";
} else if ((data.metrics.runwayDays ?? 0) < 14) { } else if ((metrics.runwayDays ?? 0) < 14) {
badgeType = "expense"; badgeType = "expense";
} else if ((data.metrics.runwayDays ?? 0) < 30) { } else if ((metrics.runwayDays ?? 0) < 30) {
badgeType = "warning"; badgeType = "warning";
} }
...@@ -54,7 +64,7 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) ...@@ -54,7 +64,7 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
</div> </div>
<Badge type={badgeType} className="text-xs px-3 py-1 font-semibold"> <Badge type={badgeType} className="text-xs px-3 py-1 font-semibold">
{isFinite {isFinite
? t("forecast.daysLeft", { days: data.metrics.runwayDays ?? 0 }) ? t("forecast.daysLeft", { days: metrics.runwayDays ?? 0 })
: t("forecast.sustainable")} : t("forecast.sustainable")}
</Badge> </Badge>
</div> </div>
...@@ -100,14 +110,14 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) ...@@ -100,14 +110,14 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
</div> </div>
{/* Projected Depletion Date */} {/* Projected Depletion Date */}
{data.metrics.depletionDate && ( {metrics.depletionDate && (
<div className="rounded-clay-sm border border-clay-expense/30 bg-clay-expense/10 p-3.5 flex items-center justify-between gap-3 shadow-clay-pressed"> <div className="rounded-clay-sm border border-clay-expense/30 bg-clay-expense/10 p-3.5 flex items-center justify-between gap-3 shadow-clay-pressed">
<div> <div>
<span className="block text-[11px] font-bold text-clay-text-muted"> <span className="block text-[11px] font-bold text-clay-text-muted">
{t("forecast.depletionAlert")} {t("forecast.depletionAlert")}
</span> </span>
<p className="mt-0.5 font-baloo text-base sm:text-lg font-bold text-clay-expense"> <p className="mt-0.5 font-baloo text-base sm:text-lg font-bold text-clay-expense">
{formatBusinessDate(data.metrics.depletionDate, intlLocale, { {formatBusinessDate(metrics.depletionDate, intlLocale, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
......
...@@ -22,6 +22,7 @@ import { useRecurringTransactions } from "@/hooks/use-recurring-transactions"; ...@@ -22,6 +22,7 @@ import { useRecurringTransactions } from "@/hooks/use-recurring-transactions";
import { useDiscoveredSubscriptions } from "@/hooks/use-subscriptions"; import { useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useUnreadNotificationCount } from "@/hooks/use-notifications"; import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { todayInBusinessTime, addCalendarDays } from "@/lib/business-time"; import { todayInBusinessTime, addCalendarDays } from "@/lib/business-time";
import { GuestModeBanner } from "@/components/shared/GuestModeBanner";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -85,7 +86,7 @@ function HomePage() { ...@@ -85,7 +86,7 @@ function HomePage() {
} }
} }
const discoveredItems = discoveryQuery.data?.data.items || []; const discoveredItems = discoveryQuery.data?.data?.items || [];
const unlinkedCount = discoveredItems.filter( const unlinkedCount = discoveredItems.filter(
(item) => !item.isLinkedToSchedule && !schedules.some((s) => s.description?.toLowerCase() === item.merchantName.toLowerCase()), (item) => !item.isLinkedToSchedule && !schedules.some((s) => s.description?.toLowerCase() === item.merchantName.toLowerCase()),
).length; ).length;
...@@ -93,16 +94,17 @@ function HomePage() { ...@@ -93,16 +94,17 @@ function HomePage() {
count += unlinkedCount; count += unlinkedCount;
return { recurringAlertCount: count, hasDueTodayOrOverdue: urgent }; return { recurringAlertCount: count, hasDueTodayOrOverdue: urgent };
}, [recurringQuery.data?.data, discoveryQuery.data?.data.items, today]); }, [recurringQuery.data?.data, discoveryQuery.data?.data?.items, today]);
// Unread notifications count // Unread notifications count
const unreadNotificationsQuery = useUnreadNotificationCount(); const unreadNotificationsQuery = useUnreadNotificationCount();
const unreadCount = unreadNotificationsQuery.data?.data.count || 0; const unreadCount = unreadNotificationsQuery.data?.data?.count || 0;
return ( return (
<Page className="page flex flex-col justify-start overflow-y-auto"> <Page className="page flex flex-col justify-start overflow-y-auto">
<Header title={t("home.header")} showBackIcon={false} /> <Header title={t("home.header")} showBackIcon={false} />
<IconGradients /> <IconGradients />
<GuestModeBanner />
<div className="flex flex-col items-center justify-start gap-3.5 pt-2 pb-5"> <div className="flex flex-col items-center justify-start gap-3.5 pt-2 pb-5">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
......
...@@ -37,41 +37,45 @@ interface PreviewItem { ...@@ -37,41 +37,45 @@ interface PreviewItem {
} }
function getPreviewItems(insights: AIInsightsData): PreviewItem[] { function getPreviewItems(insights: AIInsightsData): PreviewItem[] {
const anomalies = Array.isArray(insights?.anomalies) ? insights.anomalies : [];
const trends = Array.isArray(insights?.trends) ? insights.trends : [];
const recommendations = Array.isArray(insights?.recommendations) ? insights.recommendations : [];
const primaryItems: PreviewItem[] = [ const primaryItems: PreviewItem[] = [
insights.anomalies[0] && { anomalies[0] && {
key: "anomaly-0", key: "anomaly-0",
kind: "anomaly", kind: "anomaly",
title: insights.anomalies[0].title, title: anomalies[0].title,
description: insights.anomalies[0].description, description: anomalies[0].description,
}, },
insights.trends[0] && { trends[0] && {
key: "trend-0", key: "trend-0",
kind: "trend", kind: "trend",
title: insights.trends[0].title, title: trends[0].title,
description: insights.trends[0].description, description: trends[0].description,
}, },
insights.recommendations[0] && { recommendations[0] && {
key: "recommendation-0", key: "recommendation-0",
kind: "recommendation", kind: "recommendation",
title: insights.recommendations[0].title, title: recommendations[0].title,
description: insights.recommendations[0].description, description: recommendations[0].description,
}, },
].filter((item): item is PreviewItem => Boolean(item)); ].filter((item): item is PreviewItem => Boolean(item));
const remainingItems: PreviewItem[] = [ const remainingItems: PreviewItem[] = [
...insights.anomalies.slice(1).map((item, index) => ({ ...anomalies.slice(1).map((item, index) => ({
key: `anomaly-${index + 1}`, key: `anomaly-${index + 1}`,
kind: "anomaly" as const, kind: "anomaly" as const,
title: item.title, title: item.title,
description: item.description, description: item.description,
})), })),
...insights.trends.slice(1).map((item, index) => ({ ...trends.slice(1).map((item, index) => ({
key: `trend-${index + 1}`, key: `trend-${index + 1}`,
kind: "trend" as const, kind: "trend" as const,
title: item.title, title: item.title,
description: item.description, description: item.description,
})), })),
...insights.recommendations.slice(1).map((item, index) => ({ ...recommendations.slice(1).map((item, index) => ({
key: `recommendation-${index + 1}`, key: `recommendation-${index + 1}`,
kind: "recommendation" as const, kind: "recommendation" as const,
title: item.title, title: item.title,
......
...@@ -81,9 +81,9 @@ const ReportsPage: React.FC = () => { ...@@ -81,9 +81,9 @@ const ReportsPage: React.FC = () => {
const availableCurrencies = useMemo(() => { const availableCurrencies = useMemo(() => {
if (!overview) return []; if (!overview) return [];
return Array.from(new Set([ return Array.from(new Set([
...overview.metricsByCurrency.map((item) => item.currency), ...(overview.metricsByCurrency || []).map((item) => item.currency),
...overview.budgets.byType.map((item) => item.currency), ...(overview.budgets?.byType || []).map((item) => item.currency),
...overview.savingGoals.byCurrency.map((item) => item.currency), ...(overview.savingGoals?.byCurrency || []).map((item) => item.currency),
])).sort(); ])).sort();
}, [overview]); }, [overview]);
...@@ -97,12 +97,12 @@ const ReportsPage: React.FC = () => { ...@@ -97,12 +97,12 @@ const ReportsPage: React.FC = () => {
setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || "")); setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || ""));
}, [activeCurrency, availableCurrencies.join("|"), selectedWallet, wallets]); }, [activeCurrency, availableCurrencies.join("|"), selectedWallet, wallets]);
const metric = overview?.metricsByCurrency.find((item) => item.currency === activeCurrency); const metric = overview?.metricsByCurrency?.find((item) => item.currency === activeCurrency);
const currencyWallets = overview?.wallets.items.filter((wallet) => wallet.currency === activeCurrency) || []; const currencyWallets = overview?.wallets?.items?.filter((wallet) => wallet.currency === activeCurrency) || [];
const cashFlow = cashFlowQuery.data?.data; const cashFlow = cashFlowQuery.data?.data;
const cashFlowTotal = cashFlow?.totalsByCurrency.find((item) => item.currency === activeCurrency); const cashFlowTotal = cashFlow?.totalsByCurrency?.find((item) => item.currency === activeCurrency);
const categoryData = spendingQuery.data?.data.currencies.find((item) => item.currency === activeCurrency); const categoryData = spendingQuery.data?.data?.currencies?.find((item) => item.currency === activeCurrency);
const currencyBudgets = budgetQuery.data?.data.budgets.filter((item) => item.currency === activeCurrency) || []; const currencyBudgets = budgetQuery.data?.data?.budgets?.filter((item) => item.currency === activeCurrency) || [];
const isRefreshing = [overviewQuery, cashFlowQuery, spendingQuery, budgetQuery].some((query) => query.isFetching && !query.isLoading); const isRefreshing = [overviewQuery, cashFlowQuery, spendingQuery, budgetQuery].some((query) => query.isFetching && !query.isLoading);
const handleWalletChange = (value: string) => { const handleWalletChange = (value: string) => {
......
...@@ -19,10 +19,22 @@ const statusStyle: Record<SavingGoalStatus, BadgeType> = { ...@@ -19,10 +19,22 @@ const statusStyle: Record<SavingGoalStatus, BadgeType> = {
export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) => { export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) => {
const { formatCurrency, formatDate, formatNumber, t } = useI18n(); const { formatCurrency, formatDate, formatNumber, t } = useI18n();
const progress = Number(goal.progress.progressPercentage);
const progressObj = goal.progress || {
savedAmount: (goal as any).currentAmount || "0",
remainingAmount: String(Math.max(0, Number(goal.targetAmount || 0) - Number((goal as any).currentAmount || 0))),
progressPercentage: String((goal as any).progressPercentage || 0),
contributionCount: 0,
lastContributionAt: null,
daysRemaining: 0,
isOverdue: false,
};
const progress = Number(progressObj.progressPercentage);
const isOverdue = Boolean(progressObj.isOverdue);
const progressType: BadgeType = goal.status === "COMPLETED" const progressType: BadgeType = goal.status === "COMPLETED"
? "income" ? "income"
: goal.progress.isOverdue : isOverdue
? "expense" ? "expense"
: goal.status === "PAUSED" ? "warning" : "primary"; : goal.status === "PAUSED" ? "warning" : "primary";
...@@ -50,7 +62,7 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) ...@@ -50,7 +62,7 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick })
<p className="font-nunito text-xs font-semibold text-clay-text-muted"> <p className="font-nunito text-xs font-semibold text-clay-text-muted">
{t("savingGoal.dueDate", { date: formatDate(goal.targetDate, { day: "2-digit", month: "2-digit", year: "numeric" }) })} {t("savingGoal.dueDate", { date: formatDate(goal.targetDate, { day: "2-digit", month: "2-digit", year: "numeric" }) })}
</p> </p>
{goal.progress.isOverdue && <p className="mt-1 font-nunito text-xs font-bold text-clay-expense">{t("savingGoal.overdue")}</p>} {isOverdue && <p className="mt-1 font-nunito text-xs font-bold text-clay-expense">{t("savingGoal.overdue")}</p>}
</div> </div>
<Badge type={statusStyle[goal.status]}>{t(`savingGoal.status.${goal.status.toLowerCase()}`)}</Badge> <Badge type={statusStyle[goal.status]}>{t(`savingGoal.status.${goal.status.toLowerCase()}`)}</Badge>
</div> </div>
...@@ -62,18 +74,18 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) ...@@ -62,18 +74,18 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick })
</div> </div>
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 shadow-clay-pressed"> <div className="rounded-clay-sm bg-clay-income/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.saved")}</p> <p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.saved")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-income">{formatCurrency(Number(goal.progress.savedAmount), goal.currency)}</p> <p className="mt-1 truncate font-baloo text-sm font-bold text-clay-income">{formatCurrency(Number(progressObj.savedAmount), goal.currency)}</p>
</div> </div>
<div className="rounded-clay-sm bg-clay-warning/10 p-2.5 shadow-clay-pressed"> <div className="rounded-clay-sm bg-clay-warning/10 p-2.5 shadow-clay-pressed">
<p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.remaining")}</p> <p className="font-nunito text-[11px] font-bold text-clay-text-muted">{t("savingGoal.remaining")}</p>
<p className="mt-1 truncate font-baloo text-sm font-bold text-clay-warning">{formatCurrency(Number(goal.progress.remainingAmount), goal.currency)}</p> <p className="mt-1 truncate font-baloo text-sm font-bold text-clay-warning">{formatCurrency(Number(progressObj.remainingAmount), goal.currency)}</p>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<span className="font-nunito text-xs font-bold text-clay-text-muted">{t("savingGoal.progress")}</span> <span className="font-nunito text-xs font-bold text-clay-text-muted">{t("savingGoal.progress")}</span>
<span className={`font-baloo text-base font-bold ${goal.progress.isOverdue ? "text-clay-expense" : goal.status === "COMPLETED" ? "text-clay-income" : "text-clay-text"}`}> <span className={`font-baloo text-base font-bold ${isOverdue ? "text-clay-expense" : goal.status === "COMPLETED" ? "text-clay-income" : "text-clay-text"}`}>
{formatNumber(progress, { maximumFractionDigits: 1 })}% {formatNumber(progress, { maximumFractionDigits: 1 })}%
</span> </span>
</div> </div>
......
...@@ -78,8 +78,8 @@ const SavingGoalsPage: React.FC = () => { ...@@ -78,8 +78,8 @@ const SavingGoalsPage: React.FC = () => {
const goalsQuery = useSavingGoals(query); const goalsQuery = useSavingGoals(query);
const createMutation = useCreateSavingGoal(); const createMutation = useCreateSavingGoal();
const goals = goalsQuery.data?.data || []; const goals = goalsQuery.data?.data || [];
const totalItems = goalsQuery.data?.meta.total || 0; const totalItems = goalsQuery.data?.meta?.total ?? goals.length;
const totalPages = goalsQuery.data?.meta.totalPages || 0; const totalPages = goalsQuery.data?.meta?.totalPages ?? (goals.length > 0 ? 1 : 0);
const completedOnPage = goals.filter((goal) => goal.status === "COMPLETED" && !goal.isArchived).length; const completedOnPage = goals.filter((goal) => goal.status === "COMPLETED" && !goal.isArchived).length;
useEffect(() => setPage(1), [deferredSearch, dueFilter, dueFrom, dueTo, includeArchived, sort, statusFilter]); useEffect(() => setPage(1), [deferredSearch, dueFilter, dueFrom, dueTo, includeArchived, sort, statusFilter]);
......
...@@ -257,8 +257,8 @@ const TransactionsPage: React.FC = () => { ...@@ -257,8 +257,8 @@ const TransactionsPage: React.FC = () => {
setPage(1); setPage(1);
}, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sort]); }, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sort]);
const totalItems = transactionsQuery.data?.meta.total || 0; const totalItems = transactionsQuery.data?.meta?.total ?? (transactionsQuery.data?.data?.length || 0);
const totalPages = transactionsQuery.data?.meta.totalPages || 0; const totalPages = transactionsQuery.data?.meta?.totalPages ?? (totalItems > 0 ? 1 : 0);
useEffect(() => { useEffect(() => {
if (totalPages > 0 && page > totalPages) { if (totalPages > 0 && page > totalPages) {
...@@ -378,10 +378,11 @@ const TransactionsPage: React.FC = () => { ...@@ -378,10 +378,11 @@ const TransactionsPage: React.FC = () => {
]; ];
}, [categoriesQuery.data?.data, t]); }, [categoriesQuery.data?.data, t]);
function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] { function flattenTree(nodes: CategoryTreeNode[] = [], depth = 0): FlatCategoryOption[] {
if (!Array.isArray(nodes)) return [];
return nodes.flatMap((node) => [ return nodes.flatMap((node) => [
{ category: node, depth }, { category: node, depth },
...flattenTree(node.children, depth + 1), ...flattenTree(node?.children || [], depth + 1),
]); ]);
} }
......
...@@ -354,9 +354,9 @@ const TransfersPage: React.FC = () => { ...@@ -354,9 +354,9 @@ const TransfersPage: React.FC = () => {
</Card> </Card>
)} )}
{!transfersQuery.isLoading && !transfersQuery.isError && (transfersQuery.data?.data.length || 0) > 0 && ( {!transfersQuery.isLoading && !transfersQuery.isError && (transfersQuery.data?.data?.length || 0) > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{transfersQuery.data?.data.map((transfer) => ( {(transfersQuery.data?.data || []).map((transfer) => (
<Card key={transfer.id} className="p-4"> <Card key={transfer.id} className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
......
...@@ -81,12 +81,12 @@ const WalletsPage: React.FC = () => { ...@@ -81,12 +81,12 @@ const WalletsPage: React.FC = () => {
const visibleWallets = deferredSearch const visibleWallets = deferredSearch
? filteredWallets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) ? filteredWallets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
: filteredWallets; : filteredWallets;
const totalItems = deferredSearch ? filteredWallets.length : (walletsQuery.data?.meta.total || 0); const totalItems = deferredSearch ? filteredWallets.length : (walletsQuery.data?.meta?.total ?? (walletsQuery.data?.data?.length || 0));
const totalPages = deferredSearch const totalPages = deferredSearch
? Math.ceil(filteredWallets.length / PAGE_SIZE) ? Math.ceil(filteredWallets.length / PAGE_SIZE)
: (walletsQuery.data?.meta.totalPages || 0); : (walletsQuery.data?.meta?.totalPages ?? (totalItems > 0 ? 1 : 0));
const balances = groupBalancesByCurrency(summary.data?.data || []); const balances = groupBalancesByCurrency(summary.data?.data || []);
const activeWalletCount = summary.data?.meta.total || 0; const activeWalletCount = summary.data?.meta?.total ?? (summary.data?.data?.length || 0);
useEffect(() => { useEffect(() => {
if (totalPages > 0 && page > totalPages) { if (totalPages > 0 && page > totalPages) {
......
...@@ -4,13 +4,81 @@ import { useAIChatStore } from "@/stores/ai-chat-store"; ...@@ -4,13 +4,81 @@ import { useAIChatStore } from "@/stores/ai-chat-store";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { safeStorage } from "@/lib/storage"; import { safeStorage } from "@/lib/storage";
export const GUEST_STORAGE_KEY = "finwise.isGuest";
export const GUEST_USER: User = {
id: "guest-user-session",
email: "guest@finwise.local",
fullName: "Khách trải nghiệm",
avatarUrl: "",
avatarPositionX: 50,
avatarPositionY: 50,
roleId: "guest-role",
role: {
id: "guest-role",
name: "USER",
},
permissions: [
"WALLET_READ",
"WALLET_CREATE",
"WALLET_UPDATE",
"WALLET_DELETE",
"CATEGORY_READ",
"CATEGORY_CREATE",
"CATEGORY_UPDATE",
"CATEGORY_DELETE",
"TRANSACTION_READ",
"TRANSACTION_CREATE",
"TRANSACTION_UPDATE",
"TRANSACTION_DELETE",
"TRANSFER_READ",
"TRANSFER_CREATE",
"BUDGET_READ",
"BUDGET_CREATE",
"BUDGET_UPDATE",
"BUDGET_DELETE",
"SAVING_GOAL_READ",
"SAVING_GOAL_CREATE",
"SAVING_GOAL_UPDATE",
"SAVING_GOAL_DELETE",
"REPORT_READ",
"REPORT_EXPORT",
"STATEMENT_READ",
"STATEMENT_CREATE",
"FORECAST_READ",
"SIMULATION_READ",
"ANOMALY_READ",
"RECURRING_TRANSACTION_READ",
"RECURRING_TRANSACTION_CREATE",
"QUERY_EXECUTE",
"NOTIFICATION_READ",
"AI_ASSISTANT_USE",
"DEBT_READ",
"DEBT_CREATE",
"DEBT_UPDATE",
"DEBT_DELETE",
"DEBT_SETTLE",
"SUBSCRIPTION_READ",
"SUBSCRIPTION_MANAGE",
"BACKUP_EXPORT",
"BACKUP_IMPORT",
"HANDOVER_INITIATE",
"HANDOVER_CLAIM",
],
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
interface AuthState { interface AuthState {
user: User | null; user: User | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isInitialized: boolean; isInitialized: boolean;
isGuest: boolean;
setAuth: (user?: User | null, accessToken?: string, refreshToken?: string) => void; setAuth: (user?: User | null, accessToken?: string, refreshToken?: string) => void;
setGuestMode: () => void;
clearAuth: () => void; clearAuth: () => void;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void; setInitialized: (initialized: boolean) => void;
...@@ -20,14 +88,17 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -20,14 +88,17 @@ export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from safeStorage // Pre-load tokens from safeStorage
const accessToken = safeStorage.getItem("accessToken"); const accessToken = safeStorage.getItem("accessToken");
const refreshToken = safeStorage.getItem("refreshToken"); const refreshToken = safeStorage.getItem("refreshToken");
const initialGuest = !accessToken && safeStorage.getItem(GUEST_STORAGE_KEY) === "true";
return { return {
user: null, user: initialGuest ? GUEST_USER : null,
accessToken, accessToken,
refreshToken, refreshToken,
isAuthenticated: !!accessToken, isAuthenticated: !!accessToken,
isInitialized: false, isInitialized: false,
isGuest: initialGuest,
setAuth: (user, accessToken, refreshToken) => { setAuth: (user, accessToken, refreshToken) => {
safeStorage.removeItem(GUEST_STORAGE_KEY);
if (accessToken) { if (accessToken) {
safeStorage.setItem("accessToken", accessToken); safeStorage.setItem("accessToken", accessToken);
} }
...@@ -39,11 +110,23 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -39,11 +110,23 @@ export const useAuthStore = create<AuthState>((set) => {
accessToken: accessToken || state.accessToken, accessToken: accessToken || state.accessToken,
refreshToken: refreshToken || state.refreshToken, refreshToken: refreshToken || state.refreshToken,
isAuthenticated: Boolean(accessToken || state.accessToken || user || state.user), isAuthenticated: Boolean(accessToken || state.accessToken || user || state.user),
isGuest: false,
})); }));
}, },
setGuestMode: () => {
safeStorage.setItem(GUEST_STORAGE_KEY, "true");
set({
user: GUEST_USER,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isGuest: true,
});
},
clearAuth: () => { clearAuth: () => {
safeStorage.removeItem("accessToken"); safeStorage.removeItem("accessToken");
safeStorage.removeItem("refreshToken"); safeStorage.removeItem("refreshToken");
safeStorage.removeItem(GUEST_STORAGE_KEY);
useAIChatStore.getState().clearMessages(); useAIChatStore.getState().clearMessages();
queryClient.clear(); queryClient.clear();
set({ set({
...@@ -51,10 +134,15 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -51,10 +134,15 @@ export const useAuthStore = create<AuthState>((set) => {
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
isGuest: false,
}); });
}, },
setUser: (user) => { setUser: (user) => {
set((state) => ({ user, isAuthenticated: Boolean(user || state.accessToken) })); set((state) => ({
user,
isAuthenticated: Boolean(user || state.accessToken),
isGuest: false,
}));
}, },
setInitialized: (isInitialized) => { setInitialized: (isInitialized) => {
set({ isInitialized }); set({ isInitialized });
......
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