Commit 5720c6c1 authored by ThinhNC's avatar ThinhNC

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

- Add guest trial mode entry point with repositioned CTA on Login page
- Introduce GuestModeBanner with responsive alignment and vertical centering
- Intercept guest API requests via Axios adapter with full offline mock dataset
- Support Debts & Loans module, Amortization simulator, and full permissions for Guest user
- Add client-side mock responses for AI Assistant (Chat, Insights, Recommendations) consuming 0 tokens
- Add defensive optional chaining and array fallbacks across Forecast, Saving Goals, Reports, Debts, and AI views
- Update multi-language dictionaries (vi/en) with guest mode labels and descriptions
parent c16e4af5
......@@ -9,4 +9,9 @@
- [ ] 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).
- [ ] Đả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
- Đầ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`).
- 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
- 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.
......@@ -32,3 +32,12 @@
### 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.
- Đị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 {
} from "zmp-ui";
import { QueryClientProvider } from "@tanstack/react-query";
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 { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard";
......@@ -89,9 +89,15 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
useEffect(() => {
const initAuth = async () => {
try {
const isGuest = safeStorage.getItem(GUEST_STORAGE_KEY) === "true";
const storedAccessToken = safeStorage.getItem("accessToken");
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 (!storedAccessToken && !storedRefreshToken) {
clearAuth();
......
......@@ -5,15 +5,15 @@ import { useI18n } from "@/i18n";
import { Logo } from "@/components/logo";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const { isAuthenticated, isGuest, isInitialized } = useAuthStore();
const navigate = useNavigate();
const { t } = useI18n();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
if (isInitialized && !isAuthenticated && !isGuest) {
navigate("/login", { replace: true });
}
}, [isInitialized, isAuthenticated, navigate]);
}, [isInitialized, isAuthenticated, isGuest, navigate]);
if (!isInitialized) {
return (
......@@ -24,7 +24,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
);
}
if (!isAuthenticated) {
if (!isAuthenticated && !isGuest) {
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 }) => (
</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 @@
"switchToLight": "Switch to light 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": {
"default": "Expense Journal & Financial Reports",
"home": "Home",
......@@ -171,7 +178,19 @@
"wallet": "Manage Wallets",
"report": "Reports",
"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": {
"loginButton": "Link Account",
......@@ -237,7 +256,7 @@
}
},
"home": {
"header": "FinWise Mini App",
"header": "FinWise",
"greeting": "Hello, {{name}}!",
"subtitle": "Smart Financial Management & Runway Forecasting",
"account": "Account:",
......
......@@ -43,6 +43,13 @@
"switchToLight": "Chuyển sang giao diện sáng",
"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": {
"default": "Sổ tay Chi tiêu & Báo cáo Tài chính",
"home": "Trang chủ",
......@@ -171,7 +178,19 @@
"wallet": "Quản lý ví",
"report": "Báo cáo",
"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": {
"loginButton": "Liên kết tài khoản",
......@@ -237,7 +256,7 @@
}
},
"home": {
"header": "FinWise Mini App",
"header": "FinWise",
"greeting": "Xin chào, {{name}}!",
"subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền",
"account": "Tài khoản:",
......
......@@ -25,9 +25,25 @@ const refreshClient = axios.create({
},
});
import { handleGuestRequest } from "@/lib/guest-mock";
// Request interceptor to attach bearer token
apiClient.interceptors.request.use(
(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");
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
......
This diff is collapsed.
......@@ -72,17 +72,24 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
const insights = insightsQuery.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(
insights &&
(insights.trends.length > 0 ||
insights.anomalies.length > 0 ||
insights.recommendations.length > 0)
(trends.length > 0 ||
anomalies.length > 0 ||
recommendations.length > 0)
);
const recommendationsHaveItems = Boolean(
recs &&
(recs.budgetRecommendations.length > 0 ||
recs.savingRecommendations.length > 0 ||
recs.actions.length > 0)
(budgetRecommendations.length > 0 ||
savingRecommendations.length > 0 ||
actions.length > 0)
);
const getTrendBadge = (direction: TrendDirection) => {
......@@ -220,13 +227,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
</Card>
{/* Spending Trends Section */}
{insights.trends.length > 0 && (
{trends.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="font-baloo text-sm font-bold text-clay-text px-1">
{t("ai.insights.sections.trends")}
</h4>
<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">
<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>
......@@ -244,13 +251,13 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)}
{/* Anomalies Section */}
{insights.anomalies.length > 0 && (
{anomalies.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="font-baloo text-sm font-bold text-clay-text px-1">
{t("ai.insights.sections.anomalies")}
</h4>
<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">
<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>
......@@ -268,14 +275,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)}
{/* Improvement Recommendations Section */}
{insights.recommendations.length > 0 && (
{recommendations.length > 0 && (
<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">
<AIAssistantIcon size={17} aria-hidden="true" className="text-clay-primary" />
{t("ai.insights.sections.recommendations")}
</h4>
<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">
<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">
......@@ -321,14 +328,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
</Card>
{/* Budget Recommendations */}
{recs.budgetRecommendations.length > 0 && (
{budgetRecommendations.length > 0 && (
<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">
<BudgetIcon size={17} aria-hidden="true" />
{t("ai.insights.sections.budgetRecs")}
</h4>
<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">
<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">
......@@ -356,14 +363,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)}
{/* Saving Goal Recommendations */}
{recs.savingRecommendations.length > 0 && (
{savingRecommendations.length > 0 && (
<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">
<SavingGoalIcon size={17} aria-hidden="true" />
{t("ai.insights.sections.savingRecs")}
</h4>
<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">
<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">
......@@ -391,14 +398,14 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
)}
{/* Priority Actions */}
{recs.actions.length > 0 && (
{actions.length > 0 && (
<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">
<NotificationIcon size={17} aria-hidden="true" className="text-clay-primary" />
{t("ai.insights.sections.actionRecs")}
</h4>
<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">
<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">
......
......@@ -45,6 +45,7 @@ const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const setGuestMode = useAuthStore((state) => state.setGuestMode);
const { t } = useI18n();
const [isSubmitting, setIsSubmitting] = useState(false);
......@@ -54,6 +55,21 @@ const LoginPage: React.FC = () => {
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 {
register,
handleSubmit,
......@@ -109,13 +125,15 @@ const LoginPage: React.FC = () => {
<IconGradients />
<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 */}
<div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" />
<div className="space-y-1">
<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>
......@@ -198,7 +216,7 @@ const LoginPage: React.FC = () => {
</form>
</Card>
{/* Bottom: Link to Register */}
{/* Link to Register */}
<div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "}
<span
......@@ -208,6 +226,31 @@ const LoginPage: React.FC = () => {
{t("auth.login.registerNow")}
</span>
</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>
</Page>
......
......@@ -42,10 +42,11 @@ function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undef
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) => [
...(node.type === "EXPENSE" && !node.isArchived ? [{ category: node, depth }] : []),
...flattenExpenseCategories(node.children, depth + 1),
...(node?.type === "EXPENSE" && !node?.isArchived ? [{ category: node, depth }] : []),
...flattenExpenseCategories(node?.children || [], depth + 1),
]);
}
......@@ -85,9 +86,9 @@ const BudgetsPage: React.FC = () => {
const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false });
const createMutation = useCreateBudget();
const budgets = budgetsQuery.data?.data || [];
const totalItems = budgetsQuery.data?.meta.total || 0;
const totalPages = budgetsQuery.data?.meta.totalPages || 0;
const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage.status !== "ON_TRACK").length;
const totalItems = budgetsQuery.data?.meta?.total ?? budgets.length;
const totalPages = budgetsQuery.data?.meta?.totalPages ?? (budgets.length > 0 ? 1 : 0);
const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage?.status !== "ON_TRACK").length;
useEffect(() => {
setPage(1);
......
......@@ -53,8 +53,8 @@ function sortTree(
return (new Date(left[sortBy]).getTime() - new Date(right[sortBy]).getTime()) * direction;
};
return nodes
.map((node) => ({ ...node, children: sortTree(node.children, sortBy, order, locale, t) }))
return (nodes || [])
.map((node) => ({ ...node, children: sortTree(node.children || [], sortBy, order, locale, t) }))
.sort(compare);
}
......@@ -65,10 +65,10 @@ function filterTree(
t: TranslationFunction,
): CategoryTreeNode[] {
const normalizedSearch = normalizeCategorySearch(search, locale);
if (!normalizedSearch) return nodes;
if (!normalizedSearch) return nodes || [];
return nodes.reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children, search, locale, t);
return (nodes || []).reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children || [], search, locale, t);
const localizedName = normalizeCategorySearch(getCategoryDisplayName(node, t), locale);
const apiName = normalizeCategorySearch(node.name, locale);
......@@ -81,7 +81,7 @@ function filterTree(
}
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 = () => {
......
......@@ -504,7 +504,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.principal")}
</span>
<span className="font-nunito font-extrabold text-sm text-clay-text">
{formatCurrency(previewData.summary.principal, "VND")}
{formatCurrency(previewData.summary?.principal ?? 0, "VND")}
</span>
</div>
<div>
......@@ -512,7 +512,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.totalInterest")}
</span>
<span className="font-nunito font-extrabold text-sm text-clay-expense">
{formatCurrency(previewData.summary.totalInterest, "VND")}
{formatCurrency(previewData.summary?.totalInterest ?? 0, "VND")}
</span>
</div>
</div>
......@@ -522,7 +522,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
{t("debts.modal.totalPayment")}
</span>
<span className="font-nunito font-extrabold text-base text-clay-primary">
{formatCurrency(previewData.summary.totalPayment, "VND")}
{formatCurrency(previewData.summary?.totalPayment ?? 0, "VND")}
</span>
</div>
</div>
......@@ -542,7 +542,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-clay-text-muted/10 font-nunito">
{previewData.installments.map((inst) => (
{(previewData.installments || []).map((inst) => (
<tr
key={inst.period}
className="hover:bg-clay-primary/5 transition-colors"
......
......@@ -40,7 +40,11 @@ const DebtsPage: React.FC = () => {
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 = () => {
setModalMode("create");
......
......@@ -19,14 +19,24 @@ const safeNumber = (value: string): number => {
export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) => {
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
? t("forecast.runwayDays", { days: data.metrics.runwayDays ?? 0 })
? t("forecast.runwayDays", { days: metrics.runwayDays ?? 0 })
: t("forecast.unlimitedRunway");
const burnRate = safeNumber(data.metrics.netDailyBurnRate);
const burnRate = safeNumber(metrics.netDailyBurnRate);
const currentBal = safeNumber(data.currentBalance);
const projectedBalance = safeNumber(data.metrics.projectedEndBalance);
const projectedBalance = safeNumber(metrics.projectedEndBalance);
const projectedChange = projectedBalance - currentBal;
const projectedChangeTone = projectedChange > 0
? "text-clay-income"
......@@ -37,9 +47,9 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
let badgeType: "income" | "warning" | "expense" = "income";
if (!isFinite) {
badgeType = "income";
} else if ((data.metrics.runwayDays ?? 0) < 14) {
} else if ((metrics.runwayDays ?? 0) < 14) {
badgeType = "expense";
} else if ((data.metrics.runwayDays ?? 0) < 30) {
} else if ((metrics.runwayDays ?? 0) < 30) {
badgeType = "warning";
}
......@@ -54,7 +64,7 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
</div>
<Badge type={badgeType} className="text-xs px-3 py-1 font-semibold">
{isFinite
? t("forecast.daysLeft", { days: data.metrics.runwayDays ?? 0 })
? t("forecast.daysLeft", { days: metrics.runwayDays ?? 0 })
: t("forecast.sustainable")}
</Badge>
</div>
......@@ -100,14 +110,14 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
</div>
{/* 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>
<span className="block text-[11px] font-bold text-clay-text-muted">
{t("forecast.depletionAlert")}
</span>
<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",
month: "2-digit",
year: "numeric",
......
......@@ -22,6 +22,7 @@ import { useRecurringTransactions } from "@/hooks/use-recurring-transactions";
import { useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { todayInBusinessTime, addCalendarDays } from "@/lib/business-time";
import { GuestModeBanner } from "@/components/shared/GuestModeBanner";
function HomePage() {
const navigate = useNavigate();
......@@ -85,7 +86,7 @@ function HomePage() {
}
}
const discoveredItems = discoveryQuery.data?.data.items || [];
const discoveredItems = discoveryQuery.data?.data?.items || [];
const unlinkedCount = discoveredItems.filter(
(item) => !item.isLinkedToSchedule && !schedules.some((s) => s.description?.toLowerCase() === item.merchantName.toLowerCase()),
).length;
......@@ -93,16 +94,17 @@ function HomePage() {
count += unlinkedCount;
return { recurringAlertCount: count, hasDueTodayOrOverdue: urgent };
}, [recurringQuery.data?.data, discoveryQuery.data?.data.items, today]);
}, [recurringQuery.data?.data, discoveryQuery.data?.data?.items, today]);
// Unread notifications count
const unreadNotificationsQuery = useUnreadNotificationCount();
const unreadCount = unreadNotificationsQuery.data?.data.count || 0;
const unreadCount = unreadNotificationsQuery.data?.data?.count || 0;
return (
<Page className="page flex flex-col justify-start overflow-y-auto">
<Header title={t("home.header")} showBackIcon={false} />
<IconGradients />
<GuestModeBanner />
<div className="flex flex-col items-center justify-start gap-3.5 pt-2 pb-5">
{/* Logo/Avatar Area */}
......
......@@ -37,41 +37,45 @@ interface 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[] = [
insights.anomalies[0] && {
anomalies[0] && {
key: "anomaly-0",
kind: "anomaly",
title: insights.anomalies[0].title,
description: insights.anomalies[0].description,
title: anomalies[0].title,
description: anomalies[0].description,
},
insights.trends[0] && {
trends[0] && {
key: "trend-0",
kind: "trend",
title: insights.trends[0].title,
description: insights.trends[0].description,
title: trends[0].title,
description: trends[0].description,
},
insights.recommendations[0] && {
recommendations[0] && {
key: "recommendation-0",
kind: "recommendation",
title: insights.recommendations[0].title,
description: insights.recommendations[0].description,
title: recommendations[0].title,
description: recommendations[0].description,
},
].filter((item): item is PreviewItem => Boolean(item));
const remainingItems: PreviewItem[] = [
...insights.anomalies.slice(1).map((item, index) => ({
...anomalies.slice(1).map((item, index) => ({
key: `anomaly-${index + 1}`,
kind: "anomaly" as const,
title: item.title,
description: item.description,
})),
...insights.trends.slice(1).map((item, index) => ({
...trends.slice(1).map((item, index) => ({
key: `trend-${index + 1}`,
kind: "trend" as const,
title: item.title,
description: item.description,
})),
...insights.recommendations.slice(1).map((item, index) => ({
...recommendations.slice(1).map((item, index) => ({
key: `recommendation-${index + 1}`,
kind: "recommendation" as const,
title: item.title,
......
......@@ -81,9 +81,9 @@ const ReportsPage: React.FC = () => {
const availableCurrencies = useMemo(() => {
if (!overview) return [];
return Array.from(new Set([
...overview.metricsByCurrency.map((item) => item.currency),
...overview.budgets.byType.map((item) => item.currency),
...overview.savingGoals.byCurrency.map((item) => item.currency),
...(overview.metricsByCurrency || []).map((item) => item.currency),
...(overview.budgets?.byType || []).map((item) => item.currency),
...(overview.savingGoals?.byCurrency || []).map((item) => item.currency),
])).sort();
}, [overview]);
......@@ -97,12 +97,12 @@ const ReportsPage: React.FC = () => {
setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || ""));
}, [activeCurrency, availableCurrencies.join("|"), selectedWallet, wallets]);
const metric = overview?.metricsByCurrency.find((item) => item.currency === activeCurrency);
const currencyWallets = overview?.wallets.items.filter((wallet) => wallet.currency === activeCurrency) || [];
const metric = overview?.metricsByCurrency?.find((item) => item.currency === activeCurrency);
const currencyWallets = overview?.wallets?.items?.filter((wallet) => wallet.currency === activeCurrency) || [];
const cashFlow = cashFlowQuery.data?.data;
const cashFlowTotal = cashFlow?.totalsByCurrency.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 cashFlowTotal = cashFlow?.totalsByCurrency?.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 isRefreshing = [overviewQuery, cashFlowQuery, spendingQuery, budgetQuery].some((query) => query.isFetching && !query.isLoading);
const handleWalletChange = (value: string) => {
......
......@@ -19,10 +19,22 @@ const statusStyle: Record<SavingGoalStatus, BadgeType> = {
export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick }) => {
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"
? "income"
: goal.progress.isOverdue
: isOverdue
? "expense"
: goal.status === "PAUSED" ? "warning" : "primary";
......@@ -50,7 +62,7 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick })
<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" }) })}
</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>
<Badge type={statusStyle[goal.status]}>{t(`savingGoal.status.${goal.status.toLowerCase()}`)}</Badge>
</div>
......@@ -62,18 +74,18 @@ export const SavingGoalCard: React.FC<SavingGoalCardProps> = ({ goal, onClick })
</div>
<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="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 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="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 className="mt-4">
<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-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 })}%
</span>
</div>
......
......@@ -78,8 +78,8 @@ const SavingGoalsPage: React.FC = () => {
const goalsQuery = useSavingGoals(query);
const createMutation = useCreateSavingGoal();
const goals = goalsQuery.data?.data || [];
const totalItems = goalsQuery.data?.meta.total || 0;
const totalPages = goalsQuery.data?.meta.totalPages || 0;
const totalItems = goalsQuery.data?.meta?.total ?? goals.length;
const totalPages = goalsQuery.data?.meta?.totalPages ?? (goals.length > 0 ? 1 : 0);
const completedOnPage = goals.filter((goal) => goal.status === "COMPLETED" && !goal.isArchived).length;
useEffect(() => setPage(1), [deferredSearch, dueFilter, dueFrom, dueTo, includeArchived, sort, statusFilter]);
......
......@@ -257,8 +257,8 @@ const TransactionsPage: React.FC = () => {
setPage(1);
}, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sort]);
const totalItems = transactionsQuery.data?.meta.total || 0;
const totalPages = transactionsQuery.data?.meta.totalPages || 0;
const totalItems = transactionsQuery.data?.meta?.total ?? (transactionsQuery.data?.data?.length || 0);
const totalPages = transactionsQuery.data?.meta?.totalPages ?? (totalItems > 0 ? 1 : 0);
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
......@@ -378,10 +378,11 @@ const TransactionsPage: React.FC = () => {
];
}, [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) => [
{ category: node, depth },
...flattenTree(node.children, depth + 1),
...flattenTree(node?.children || [], depth + 1),
]);
}
......
......@@ -354,9 +354,9 @@ const TransfersPage: React.FC = () => {
</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">
{transfersQuery.data?.data.map((transfer) => (
{(transfersQuery.data?.data || []).map((transfer) => (
<Card key={transfer.id} className="p-4">
<div className="flex items-start gap-3">
<div className="flex shrink-0 items-center">
......
......@@ -81,12 +81,12 @@ const WalletsPage: React.FC = () => {
const visibleWallets = deferredSearch
? filteredWallets.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
: 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
? 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 activeWalletCount = summary.data?.meta.total || 0;
const activeWalletCount = summary.data?.meta?.total ?? (summary.data?.data?.length || 0);
useEffect(() => {
if (totalPages > 0 && page > totalPages) {
......
......@@ -4,13 +4,81 @@ import { useAIChatStore } from "@/stores/ai-chat-store";
import { queryClient } from "@/lib/query-client";
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 {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
isGuest: boolean;
setAuth: (user?: User | null, accessToken?: string, refreshToken?: string) => void;
setGuestMode: () => void;
clearAuth: () => void;
setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void;
......@@ -20,14 +88,17 @@ export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from safeStorage
const accessToken = safeStorage.getItem("accessToken");
const refreshToken = safeStorage.getItem("refreshToken");
const initialGuest = !accessToken && safeStorage.getItem(GUEST_STORAGE_KEY) === "true";
return {
user: null,
user: initialGuest ? GUEST_USER : null,
accessToken,
refreshToken,
isAuthenticated: !!accessToken,
isInitialized: false,
isGuest: initialGuest,
setAuth: (user, accessToken, refreshToken) => {
safeStorage.removeItem(GUEST_STORAGE_KEY);
if (accessToken) {
safeStorage.setItem("accessToken", accessToken);
}
......@@ -39,11 +110,23 @@ export const useAuthStore = create<AuthState>((set) => {
accessToken: accessToken || state.accessToken,
refreshToken: refreshToken || state.refreshToken,
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: () => {
safeStorage.removeItem("accessToken");
safeStorage.removeItem("refreshToken");
safeStorage.removeItem(GUEST_STORAGE_KEY);
useAIChatStore.getState().clearMessages();
queryClient.clear();
set({
......@@ -51,10 +134,15 @@ export const useAuthStore = create<AuthState>((set) => {
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isGuest: false,
});
},
setUser: (user) => {
set((state) => ({ user, isAuthenticated: Boolean(user || state.accessToken) }));
set((state) => ({
user,
isAuthenticated: Boolean(user || state.accessToken),
isGuest: false,
}));
},
setInitialized: (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