Commit dd816551 authored by ThinhNC's avatar ThinhNC

fix(fe): resolve full-project audit findings, enforce type safety, and harden a11y & resilience

parent 9ccf6885
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
/> />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<meta name="theme-color" content="#F3F0FA" /> <meta name="theme-color" content="#F3F0FA" />
......
...@@ -18,7 +18,8 @@ ...@@ -18,7 +18,8 @@
"login": "zmp login", "login": "zmp login",
"start": "zmp start", "start": "zmp start",
"deploy": "zmp deploy", "deploy": "zmp deploy",
"build": "vite build" "typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1", "@hookform/resolvers": "^5.7.1",
...@@ -44,6 +45,7 @@ ...@@ -44,6 +45,7 @@
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"sass": "^1.76.0", "sass": "^1.76.0",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.13", "vite": "^5.2.13",
"zmp-vite-plugin": "latest" "zmp-vite-plugin": "latest"
} }
......
...@@ -72,6 +72,9 @@ importers: ...@@ -72,6 +72,9 @@ importers:
tailwindcss: tailwindcss:
specifier: ^3.4.3 specifier: ^3.4.3
version: 3.4.19 version: 3.4.19
typescript:
specifier: ^5.4.5
version: 5.9.3
vite: vite:
specifier: ^5.2.13 specifier: ^5.2.13
version: 5.4.21(sass@1.102.0) version: 5.4.21(sass@1.102.0)
...@@ -1783,6 +1786,11 @@ packages: ...@@ -1783,6 +1786,11 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
uniq@1.0.1: uniq@1.0.1:
resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==} resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
...@@ -3480,6 +3488,8 @@ snapshots: ...@@ -3480,6 +3488,8 @@ snapshots:
tslib@2.8.1: {} tslib@2.8.1: {}
typescript@5.9.3: {}
uniq@1.0.1: {} uniq@1.0.1: {}
universalify@2.0.1: {} universalify@2.0.1: {}
......
import React, { Component, ErrorInfo, ReactNode } from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[ErrorBoundary caught an error]:", error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
};
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen w-full bg-clay-bg flex items-center justify-center p-4">
<Card className="max-w-md w-full p-6 text-center flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-clay bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-pressed text-2xl font-bold font-baloo">
!
</div>
<div>
<h2 className="clay-title-h2">Đã xảy ra lỗi</h2>
<p className="clay-caption mt-1.5 text-clay-text-muted">
{this.state.error?.message || "Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại hoặc quay về trang chủ."}
</p>
</div>
<div className="flex gap-3 mt-2 w-full">
<Button
variant="secondary"
fullWidth
onClick={() => window.location.reload()}
>
Tải lại
</Button>
<Button
variant="primary"
fullWidth
onClick={this.handleReset}
>
Về trang chủ
</Button>
</div>
</Card>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
import { useEffect, useState } from "react";
import { Text } from "zmp-ui";
import { useI18n } from "@/i18n";
function Clock() {
const { formatDate } = useI18n();
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = formatDate(now, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
setTime(formattedTime);
};
updateClock();
const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId);
}, [formatDate]);
return <Text className="font-mono">{time}</Text>;
}
export default Clock;
This diff is collapsed.
This diff is collapsed.
...@@ -5,6 +5,7 @@ interface CategoryArtworkProps { ...@@ -5,6 +5,7 @@ interface CategoryArtworkProps {
color?: string | null; color?: string | null;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
archived?: boolean; archived?: boolean;
className?: string;
} }
const dimensions = { const dimensions = {
...@@ -67,10 +68,11 @@ export const CategoryArtwork: React.FC<CategoryArtworkProps> = ({ ...@@ -67,10 +68,11 @@ export const CategoryArtwork: React.FC<CategoryArtworkProps> = ({
color = "#8B7CF6", color = "#8B7CF6",
size = "md", size = "md",
archived = false, archived = false,
className = "",
}) => ( }) => (
<div <div
aria-hidden="true" aria-hidden="true"
className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""}`} className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""} ${className}`}
style={{ backgroundColor: color || "#8B7CF6" }} style={{ backgroundColor: color || "#8B7CF6" }}
> >
<svg <svg
......
...@@ -31,6 +31,7 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -31,6 +31,7 @@ export const WalletCard: React.FC<WalletCardProps> = ({
tabIndex={0} tabIndex={0}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick(); onClick();
} }
}} }}
......
import React from "react"; import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost"; variant?: "primary" | "secondary" | "ghost" | "danger";
shape?: "clay" | "pill"; shape?: "clay" | "pill";
fullWidth?: boolean; fullWidth?: boolean;
} }
...@@ -20,6 +20,7 @@ export const Button: React.FC<ButtonProps> = ({ ...@@ -20,6 +20,7 @@ export const Button: React.FC<ButtonProps> = ({
primary: "bg-clay-primary text-clay-on-primary border-2 border-clay-primary-dark/30 hover:bg-clay-primary/95 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]", primary: "bg-clay-primary text-clay-on-primary border-2 border-clay-primary-dark/30 hover:bg-clay-primary/95 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
secondary: "bg-clay-surface text-clay-text border-2 border-clay-text/10 hover:bg-clay-surface/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]", secondary: "bg-clay-surface text-clay-text border-2 border-clay-text/10 hover:bg-clay-surface/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]", ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]",
danger: "bg-clay-expense text-white border-2 border-clay-expense/30 hover:bg-clay-expense/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
}; };
// Border radius shapes // Border radius shapes
......
This diff is collapsed.
...@@ -8,14 +8,34 @@ export const Card: React.FC<CardProps> = ({ ...@@ -8,14 +8,34 @@ export const Card: React.FC<CardProps> = ({
children, children,
hoverable = false, hoverable = false,
className = "", className = "",
onClick,
onKeyDown,
role,
tabIndex,
...props ...props
}) => { }) => {
const isClickable = Boolean(onClick);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (onKeyDown) {
onKeyDown(event);
}
if (!event.defaultPrevented && isClickable && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}
};
return ( return (
<div <div
role={role ?? (isClickable ? "button" : undefined)}
tabIndex={tabIndex ?? (isClickable ? 0 : undefined)}
onClick={onClick}
onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined}
className={` className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6 bg-clay-surface rounded-clay-lg shadow-clay-raised p-6
transition-all duration-200 ease-in-out border border-clay-highlight/40 transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""} ${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className} ${className}
`} `}
{...props} {...props}
......
...@@ -19,17 +19,25 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -19,17 +19,25 @@ export const Modal: React.FC<ModalProps> = ({
footer, footer,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
// Prevent background scrolling when open // Prevent background scrolling when open and handle Escape key
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
return undefined;
} }
document.body.style.overflow = "hidden";
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
window.removeEventListener("keydown", handleKeyDown);
}; };
}, [isOpen]); }, [isOpen, onClose]);
if (!isOpen) return null; if (!isOpen) return null;
...@@ -43,6 +51,9 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -43,6 +51,9 @@ export const Modal: React.FC<ModalProps> = ({
{/* Modal Container */} {/* Modal Container */}
<div <div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className=" className="
relative w-full max-w-lg bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg relative w-full max-w-lg bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg
shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-6 flex flex-col gap-4 shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-6 flex flex-col gap-4
...@@ -51,7 +62,7 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -51,7 +62,7 @@ export const Modal: React.FC<ModalProps> = ({
> >
{/* Header */} {/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10"> <div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3> <h3 id="modal-title" className="clay-title-h3">{title}</h3>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
......
...@@ -21,7 +21,9 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>( ...@@ -21,7 +21,9 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
id, id,
...props ...props
}, ref) => { }, ref) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`; const generatedId = React.useId();
const selectId = id || generatedId;
const errorId = `${selectId}-error`;
return ( return (
<div className="flex flex-col gap-2 w-full relative"> <div className="flex flex-col gap-2 w-full relative">
...@@ -34,6 +36,8 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>( ...@@ -34,6 +36,8 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<select <select
id={selectId} id={selectId}
ref={ref} ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={` className={`
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10 w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10
rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none
...@@ -56,7 +60,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>( ...@@ -56,7 +60,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
</div> </div>
</div> </div>
{error && ( {error && (
<span className="font-nunito text-xs text-clay-expense px-1"> <span id={errorId} className="font-nunito text-xs text-clay-expense px-1">
{error} {error}
</span> </span>
)} )}
......
...@@ -20,6 +20,7 @@ export const Tabs: React.FC<TabsProps> = ({ ...@@ -20,6 +20,7 @@ export const Tabs: React.FC<TabsProps> = ({
}) => { }) => {
return ( return (
<div <div
role="tablist"
className={` className={`
bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none
${className} ${className}
...@@ -30,6 +31,9 @@ export const Tabs: React.FC<TabsProps> = ({ ...@@ -30,6 +31,9 @@ export const Tabs: React.FC<TabsProps> = ({
return ( return (
<button <button
key={tab.key} key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={` className={`
flex-1 text-center py-2 px-2 sm:px-4 text-xs sm:text-sm leading-tight font-baloo font-semibold rounded-full flex-1 text-center py-2 px-2 sm:px-4 text-xs sm:text-sm leading-tight font-baloo font-semibold rounded-full
......
...@@ -99,6 +99,12 @@ ...@@ -99,6 +99,12 @@
--shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45); --shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45);
} }
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
html, html,
body, body,
#app { #app {
...@@ -108,9 +114,12 @@ body, ...@@ -108,9 +114,12 @@ body,
body { body {
font-family: "Nunito", sans-serif; font-family: "Nunito", sans-serif;
font-size: 16px;
margin: 0; margin: 0;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
transition: transition:
background-color 200ms ease-in-out, background-color 200ms ease-in-out,
color 200ms ease-in-out; color 200ms ease-in-out;
...@@ -133,15 +142,15 @@ body { ...@@ -133,15 +142,15 @@ body {
.clay-title-h1 { .clay-title-h1 {
font-family: "Baloo 2", sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 700; font-weight: 700;
font-size: 32px; font-size: clamp(24px, 6vw, 30px);
line-height: 1.2; line-height: 1.25;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
} }
.clay-title-h2 { .clay-title-h2 {
font-family: "Baloo 2", sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 600; font-weight: 600;
font-size: 24px; font-size: clamp(19px, 5vw, 24px);
line-height: 1.3; line-height: 1.3;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
} }
...@@ -149,8 +158,8 @@ body { ...@@ -149,8 +158,8 @@ body {
.clay-title-h3 { .clay-title-h3 {
font-family: "Baloo 2", sans-serif; font-family: "Baloo 2", sans-serif;
font-weight: 600; font-weight: 600;
font-size: 18px; font-size: clamp(15px, 4vw, 18px);
line-height: 1.4; line-height: 1.35;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
} }
...@@ -259,7 +268,14 @@ body { ...@@ -259,7 +268,14 @@ body {
color 200ms ease-in-out; color 200ms ease-in-out;
} }
.zaui-header .zaui-header-title, .zaui-header .zaui-header-title {
color: rgb(var(--color-clay-text));
transition: color 200ms ease-in-out;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zaui-header .zaui-header-back-btn, .zaui-header .zaui-header-back-btn,
.zaui-header .zaui-header-back-btn .zaui-icon { .zaui-header .zaui-header-back-btn .zaui-icon {
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
...@@ -276,12 +292,26 @@ body { ...@@ -276,12 +292,26 @@ body {
); );
} }
/* Reserve space for native right-buttons (96px), three 36px controls, and gaps. */ /* Reserve space for native right-buttons (96px), three controls, and gaps. */
.zaui-header { .zaui-header {
padding-right: calc( padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
); );
} }
@media (max-width: 480px) {
.finwise-header-controls {
gap: 4px;
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 88px
);
}
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
);
}
}
input.finwise-localized-date { input.finwise-localized-date {
color: transparent; color: transparent;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
......
...@@ -37,7 +37,7 @@ export function useUpdateSetting() { ...@@ -37,7 +37,7 @@ export function useUpdateSetting() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ key, data }: { key: string; data: { value: any; description?: string } }) => mutationFn: ({ key, data }: { key: string; data: { value: unknown; description?: string } }) =>
adminSettingsService.updateSetting(key, data), adminSettingsService.updateSetting(key, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all }); queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
......
...@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; ...@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { API_BASE_URL } from "@/lib/api-client"; import { API_BASE_URL } from "@/lib/api-client";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { notificationKeys } from "@/hooks/use-notifications"; import { notificationKeys } from "@/hooks/use-notifications";
import { safeStorage } from "@/lib/storage";
import { NotificationItem } from "@/types/notification"; import { NotificationItem } from "@/types/notification";
export interface UseNotificationSseOptions { export interface UseNotificationSseOptions {
...@@ -27,13 +28,15 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) { ...@@ -27,13 +28,15 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
return; return;
} }
const token = accessToken || localStorage.getItem("accessToken"); const token = accessToken || safeStorage.getItem("accessToken");
const streamUrl = new URL(`${API_BASE_URL}/notifications/stream`); const streamUrl = new URL(`${API_BASE_URL}/notifications/stream`);
if (token) { if (token) {
streamUrl.searchParams.set("token", token); streamUrl.searchParams.set("token", token);
} }
let eventSource: EventSource | null = null; let eventSource: EventSource | null = null;
let retryCount = 0;
const MAX_RETRIES = 5;
try { try {
eventSource = new EventSource(streamUrl.toString(), { eventSource = new EventSource(streamUrl.toString(), {
...@@ -41,10 +44,12 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) { ...@@ -41,10 +44,12 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
}); });
eventSource.addEventListener("open", () => { eventSource.addEventListener("open", () => {
retryCount = 0;
setIsConnected(true); setIsConnected(true);
}); });
eventSource.addEventListener("connected", () => { eventSource.addEventListener("connected", () => {
retryCount = 0;
setIsConnected(true); setIsConnected(true);
}); });
...@@ -78,9 +83,13 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) { ...@@ -78,9 +83,13 @@ export function useNotificationSSE(options?: UseNotificationSseOptions) {
eventSource.onerror = () => { eventSource.onerror = () => {
setIsConnected(false); setIsConnected(false);
// If the connection was rejected (e.g. token expired or unauthorized) retryCount += 1;
if (eventSource && eventSource.readyState === EventSource.CLOSED) { // If unauthenticated or exceeded max retry attempts, close connection to prevent reconnection loop
eventSource.close(); if (retryCount >= MAX_RETRIES || !useAuthStore.getState().isAuthenticated) {
if (eventSource) {
eventSource.close();
eventSource = null;
}
} }
}; };
} catch (error) { } catch (error) {
......
...@@ -8,6 +8,8 @@ import { ...@@ -8,6 +8,8 @@ import {
UpdateSavingContributionInput, UpdateSavingContributionInput,
UpdateSavingGoalInput, UpdateSavingGoalInput,
} from "@/types/saving-goal"; } from "@/types/saving-goal";
import { walletKeys } from "./use-wallets";
import { reportKeys } from "./use-reports";
export const savingGoalKeys = { export const savingGoalKeys = {
all: ["saving-goals"] as const, all: ["saving-goals"] as const,
...@@ -96,6 +98,8 @@ function useContributionMutation(id: string) { ...@@ -96,6 +98,8 @@ function useContributionMutation(id: string) {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: savingGoalKeys.contributions(id) }), queryClient.invalidateQueries({ queryKey: savingGoalKeys.contributions(id) }),
queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() }), queryClient.invalidateQueries({ queryKey: savingGoalKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
]); ]);
}; };
} }
......
...@@ -3,6 +3,8 @@ import { transactionService } from "@/services/transaction.service"; ...@@ -3,6 +3,8 @@ import { transactionService } from "@/services/transaction.service";
import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction"; import { CreateTransactionInput, TransactionQuery, UpdateTransactionInput } from "@/types/transaction";
import { walletKeys } from "./use-wallets"; import { walletKeys } from "./use-wallets";
import { budgetKeys } from "./use-budgets"; import { budgetKeys } from "./use-budgets";
import { reportKeys } from "./use-reports";
import { FORECAST_QUERY_KEYS } from "./use-forecast";
export const transactionKeys = { export const transactionKeys = {
all: ["transactions"] as const, all: ["transactions"] as const,
...@@ -39,6 +41,8 @@ export function useCreateTransaction() { ...@@ -39,6 +41,8 @@ export function useCreateTransaction() {
queryClient.invalidateQueries({ queryKey: transactionKeys.all }), queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }), queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]); ]);
}, },
}); });
...@@ -54,6 +58,8 @@ export function useUpdateTransaction(id: string) { ...@@ -54,6 +58,8 @@ export function useUpdateTransaction(id: string) {
queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }), queryClient.invalidateQueries({ queryKey: transactionKeys.lists() }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }), queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]); ]);
}, },
}); });
...@@ -68,6 +74,8 @@ export function useDeleteTransaction() { ...@@ -68,6 +74,8 @@ export function useDeleteTransaction() {
queryClient.invalidateQueries({ queryKey: transactionKeys.all }), queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: budgetKeys.all }), queryClient.invalidateQueries({ queryKey: budgetKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
queryClient.invalidateQueries({ queryKey: FORECAST_QUERY_KEYS.all }),
]); ]);
}, },
}); });
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { transferService } from "@/services/transfer.service"; import { transferService } from "@/services/transfer.service";
import { CreateTransferInput, TransferQuery } from "@/types/transfer"; import { CreateTransferInput, TransferQuery } from "@/types/transfer";
import { reportKeys } from "@/hooks/use-reports";
import { transactionKeys } from "@/hooks/use-transactions"; import { transactionKeys } from "@/hooks/use-transactions";
import { walletKeys } from "@/hooks/use-wallets"; import { walletKeys } from "@/hooks/use-wallets";
...@@ -31,6 +32,7 @@ function useTransferMutation<TVariables>( ...@@ -31,6 +32,7 @@ function useTransferMutation<TVariables>(
queryClient.invalidateQueries({ queryKey: transferKeys.all }), queryClient.invalidateQueries({ queryKey: transferKeys.all }),
queryClient.invalidateQueries({ queryKey: walletKeys.all }), queryClient.invalidateQueries({ queryKey: walletKeys.all }),
queryClient.invalidateQueries({ queryKey: transactionKeys.all }), queryClient.invalidateQueries({ queryKey: transactionKeys.all }),
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
]); ]);
}, },
}); });
......
...@@ -40,23 +40,25 @@ export function useZaloLogin(): UseZaloLoginReturn { ...@@ -40,23 +40,25 @@ export function useZaloLogin(): UseZaloLoginReturn {
} }
try { try {
const authResult = await authorize({ scopes: ["scope.userInfo"] }); await authorize({ scopes: ["scope.userInfo"] });
console.log("[ZaloLogin] authorize result:", authResult);
} catch (authErr) { } catch (authErr) {
console.warn("[ZaloLogin] authorize scope.userInfo error or dismissed:", authErr); console.warn("[ZaloLogin] authorize scope.userInfo error or dismissed:", authErr);
} }
try { try {
const infoResult: any = await getUserInfo({ autoRequestPermission: true }); const rawInfo = (await getUserInfo({ autoRequestPermission: true })) as unknown as {
console.log("[ZaloLogin] getUserInfo raw result:", infoResult); userInfo?: { id?: string; name?: string; avatar?: string };
const userObj = infoResult?.userInfo || infoResult; id?: string;
name?: string;
avatar?: string;
};
const userObj = rawInfo?.userInfo || rawInfo;
if (userObj) { if (userObj) {
if (userObj.id) zaloId = userObj.id; if (userObj.id) zaloId = userObj.id;
if (userObj.name) name = userObj.name; if (userObj.name) name = userObj.name;
if (userObj.avatar) avatar = userObj.avatar; if (userObj.avatar) avatar = userObj.avatar;
} }
console.log("[ZaloLogin] resolved user profile:", { zaloId, name, avatar }); } catch (infoErr) {
} catch (infoErr: any) {
console.warn("[ZaloLogin] getUserInfo error:", infoErr); console.warn("[ZaloLogin] getUserInfo error:", infoErr);
} }
......
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { normalizeDateTimeFormatOptions } from "@/lib/date-format"; import { normalizeDateTimeFormatOptions } from "@/lib/date-format";
import { safeStorage } from "@/lib/storage";
import en from "./locales/en.json"; import en from "./locales/en.json";
import vi from "./locales/vi.json"; import vi from "./locales/vi.json";
...@@ -28,12 +29,8 @@ interface I18nContextValue { ...@@ -28,12 +29,8 @@ interface I18nContextValue {
const I18nContext = createContext<I18nContextValue | null>(null); const I18nContext = createContext<I18nContextValue | null>(null);
function readStoredLocale(): Locale { function readStoredLocale(): Locale {
try { const stored = safeStorage.getItem(LOCALE_STORAGE_KEY);
const stored = localStorage.getItem(LOCALE_STORAGE_KEY); return stored && stored in resources ? stored as Locale : "vi";
return stored && stored in resources ? stored as Locale : "vi";
} catch {
return "vi";
}
} }
function resolveTranslation(locale: Locale, key: string): string { function resolveTranslation(locale: Locale, key: string): string {
...@@ -59,11 +56,7 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children ...@@ -59,11 +56,7 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
const setLocale = useCallback((nextLocale: Locale) => { const setLocale = useCallback((nextLocale: Locale) => {
setLocaleState(nextLocale); setLocaleState(nextLocale);
try { safeStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
} catch {
// Language switching still works when storage is unavailable in a restricted webview.
}
}, []); }, []);
const toggleLocale = useCallback(() => { const toggleLocale = useCallback(() => {
......
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { safeStorage } from "@/lib/storage";
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:7777/api/v1"; export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:7777/api/v1";
const apiBaseUrl = API_BASE_URL; const apiBaseUrl = API_BASE_URL;
...@@ -27,7 +28,7 @@ const refreshClient = axios.create({ ...@@ -27,7 +28,7 @@ const refreshClient = axios.create({
// Request interceptor to attach bearer token // Request interceptor to attach bearer token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
const accessToken = useAuthStore.getState().accessToken || localStorage.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}`;
} }
...@@ -39,10 +40,15 @@ apiClient.interceptors.request.use( ...@@ -39,10 +40,15 @@ apiClient.interceptors.request.use(
); );
// Response interceptor to handle token refresh // Response interceptor to handle token refresh
interface QueuedRequest {
resolve: (token: string | null) => void;
reject: (error: unknown) => void;
}
let isRefreshing = false; let isRefreshing = false;
let failedQueue: any[] = []; let failedQueue: QueuedRequest[] = [];
const processQueue = (error: any, token: string | null = null) => { const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error); prom.reject(error);
...@@ -65,7 +71,10 @@ apiClient.interceptors.response.use( ...@@ -65,7 +71,10 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes('/auth/register') || originalRequest.url?.includes('/auth/register') ||
originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/refresh') ||
originalRequest.url?.includes('/auth/forgot-password') || originalRequest.url?.includes('/auth/forgot-password') ||
originalRequest.url?.includes('/auth/reset-password'); originalRequest.url?.includes('/auth/reset-password') ||
originalRequest.url?.includes('/auth/zalo-login') ||
originalRequest.url?.includes('/auth/me') ||
originalRequest.url?.includes('/auth/logout');
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet // Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) { if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
...@@ -74,12 +83,13 @@ apiClient.interceptors.response.use( ...@@ -74,12 +83,13 @@ apiClient.interceptors.response.use(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ failedQueue.push({
resolve: (token: string | null) => { resolve: (token: string | null) => {
originalRequest._retry = true;
if (token) { if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`; originalRequest.headers.Authorization = `Bearer ${token}`;
} }
resolve(apiClient(originalRequest)); resolve(apiClient(originalRequest));
}, },
reject: (err: any) => { reject: (err: unknown) => {
reject(err); reject(err);
}, },
}); });
...@@ -90,7 +100,7 @@ apiClient.interceptors.response.use( ...@@ -90,7 +100,7 @@ apiClient.interceptors.response.use(
isRefreshing = true; isRefreshing = true;
try { try {
const storedRefreshToken = useAuthStore.getState().refreshToken || localStorage.getItem("refreshToken"); const storedRefreshToken = useAuthStore.getState().refreshToken || safeStorage.getItem("refreshToken");
// Try refreshing the token (cookies will be sent automatically, body as fallback for mobile webviews) // Try refreshing the token (cookies will be sent automatically, body as fallback for mobile webviews)
const response = await refreshClient.post("/auth/refresh", { const response = await refreshClient.post("/auth/refresh", {
refreshToken: storedRefreshToken || undefined, refreshToken: storedRefreshToken || undefined,
...@@ -98,16 +108,17 @@ apiClient.interceptors.response.use( ...@@ -98,16 +108,17 @@ apiClient.interceptors.response.use(
if (response.data?.success) { if (response.data?.success) {
const newData = response.data?.data; const newData = response.data?.data;
let newAccessToken = null; let newAccessToken: string | null = null;
if (newData && newData.accessToken) { if (newData && typeof newData.accessToken === "string") {
newAccessToken = newData.accessToken; const tokenStr = newData.accessToken;
newAccessToken = tokenStr;
useAuthStore.getState().setAuth( useAuthStore.getState().setAuth(
useAuthStore.getState().user, useAuthStore.getState().user,
newAccessToken, tokenStr,
newData.refreshToken newData.refreshToken || ""
); );
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${tokenStr}`;
} }
processQueue(null, newAccessToken); processQueue(null, newAccessToken);
......
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
export const positiveAmountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; export const positiveAmountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } { export function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5); const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return { return {
group: parts.find((part) => part.type === "group")?.value || ",", group: parts.find((part) => part.type === "group")?.value || ",",
...@@ -16,6 +18,8 @@ export function formatMoneyInput(value: string, locale: string): string { ...@@ -16,6 +18,8 @@ export function formatMoneyInput(value: string, locale: string): string {
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`; return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
} }
export const formatAmountInput = formatMoneyInput;
export function parseMoneyInput(value: string, locale: string): string { export function parseMoneyInput(value: string, locale: string): string {
const trimmedValue = value.trim(); const trimmedValue = value.trim();
if (!trimmedValue) return ""; if (!trimmedValue) return "";
...@@ -40,6 +44,8 @@ export function parseMoneyInput(value: string, locale: string): string { ...@@ -40,6 +44,8 @@ export function parseMoneyInput(value: string, locale: string): string {
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`; return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
} }
export const parseAmountInput = parseMoneyInput;
export function toLocalDate(value?: string): string { export function toLocalDate(value?: string): string {
if (!value) return todayInBusinessTime(); if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value); return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
...@@ -48,4 +54,3 @@ export function toLocalDate(value?: string): string { ...@@ -48,4 +54,3 @@ export function toLocalDate(value?: string): string {
export function toLocalDateTime(value?: string): string { export function toLocalDateTime(value?: string): string {
return instantToBusinessDateTimeInput(value); return instantToBusinessDateTimeInput(value);
} }
import { instantToBusinessDate, instantToBusinessDateTimeInput, todayInBusinessTime } from "./business-time";
/**
* Safe wrapper for localStorage that gracefully falls back to an in-memory
* map when localStorage is unavailable or blocked by browser security policies.
*/
const memoryStore = new Map<string, string>();
function isLocalStorageAvailable(): boolean {
try {
if (typeof window === "undefined" || !window.localStorage) {
return false;
}
const testKey = "__finwise_storage_test__";
window.localStorage.setItem(testKey, "1");
window.localStorage.removeItem(testKey);
return true;
} catch {
return false;
}
}
const canUseLocalStorage = isLocalStorageAvailable();
export const safeStorage = {
getItem(key: string): string | null {
if (canUseLocalStorage) {
try {
return window.localStorage.getItem(key);
} catch {
return memoryStore.get(key) ?? null;
}
}
return memoryStore.get(key) ?? null;
},
setItem(key: string, value: string): void {
if (canUseLocalStorage) {
try {
window.localStorage.setItem(key, value);
return;
} catch {
// Fall through to in-memory store
}
}
memoryStore.set(key, value);
},
removeItem(key: string): void {
if (canUseLocalStorage) {
try {
window.localStorage.removeItem(key);
} catch {
// Fall through to in-memory store
}
}
memoryStore.delete(key);
},
clear(): void {
if (canUseLocalStorage) {
try {
window.localStorage.clear();
} catch {
// Fall through to in-memory store
}
}
memoryStore.clear();
},
};
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui'; import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
...@@ -32,13 +32,15 @@ import { ...@@ -32,13 +32,15 @@ import {
AiRequestStatus, AiRequestStatus,
} from '@/services/admin-ai.service'; } from '@/services/admin-ai.service';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton'; import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
type TabType = 'status' | 'usage' | 'logs' | 'rate-limit'; type TabType = 'status' | 'usage' | 'logs' | 'rate-limit';
export const AdminAiPage: React.FC = () => { export const AdminAiPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, formatNumber } = useI18n(); const { openSnackbar } = useSnackbar();
const { t, formatNumber, formatDate } = useI18n();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.AI_CONFIG_UPDATE); const canUpdateConfig = hasPermission(PERMISSIONS.AI_CONFIG_UPDATE);
const canReadUsage = hasPermission(PERMISSIONS.AI_USAGE_READ); const canReadUsage = hasPermission(PERMISSIONS.AI_USAGE_READ);
...@@ -88,8 +90,15 @@ export const AdminAiPage: React.FC = () => { ...@@ -88,8 +90,15 @@ export const AdminAiPage: React.FC = () => {
featureKey: feature.key, featureKey: feature.key,
enabled: !feature.enabled, enabled: !feature.enabled,
}); });
} catch (err: any) { openSnackbar({
alert(err?.response?.data?.message || t('admin.ai.features.toggleError') || 'Chuyển đổi trạng thái tính năng AI thất bại'); type: 'success',
text: t('admin.ai.features.toggleSuccess') || 'Đã cập nhật trạng thái tính năng AI',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.ai.features.toggleError') || 'Chuyển đổi trạng thái tính năng AI thất bại'),
});
} }
}; };
...@@ -100,9 +109,15 @@ export const AdminAiPage: React.FC = () => { ...@@ -100,9 +109,15 @@ export const AdminAiPage: React.FC = () => {
windowMs: Number(rateLimitWindowMinutes) * 60 * 1000, windowMs: Number(rateLimitWindowMinutes) * 60 * 1000,
}); });
setRateLimitDirty(false); setRateLimitDirty(false);
alert(t('admin.ai.rateLimit.success') || 'Đã cập nhật giới hạn AI Rate Limit thành công'); openSnackbar({
} catch (err: any) { type: 'success',
alert(err?.response?.data?.message || t('admin.ai.rateLimit.error') || 'Cập nhật giới hạn thất bại'); text: t('admin.ai.rateLimit.success') || 'Đã cập nhật giới hạn AI Rate Limit thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.ai.rateLimit.error') || 'Cập nhật giới hạn thất bại'),
});
} }
}; };
...@@ -405,7 +420,7 @@ export const AdminAiPage: React.FC = () => { ...@@ -405,7 +420,7 @@ export const AdminAiPage: React.FC = () => {
</div> </div>
<span className="text-[11px] text-clay-text-muted"> <span className="text-[11px] text-clay-text-muted">
{new Date(log.createdAt).toLocaleString()} {log.createdAt ? formatDate(log.createdAt, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ''}
</span> </span>
</div> </div>
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui'; import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
NotificationDeliveryStatus, NotificationDeliveryStatus,
} from '@/services/admin-notification.service'; } from '@/services/admin-notification.service';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton'; import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
function extractTemplateVariables(item?: { titleTemplate?: string; bodyTemplate?: string; variables?: string[] } | null): string[] { function extractTemplateVariables(item?: { titleTemplate?: string; bodyTemplate?: string; variables?: string[] } | null): string[] {
...@@ -48,7 +49,8 @@ type TabType = 'overview' | 'deliveries' | 'templates' | 'channels'; ...@@ -48,7 +49,8 @@ type TabType = 'overview' | 'deliveries' | 'templates' | 'channels';
export const AdminNotificationsPage: React.FC = () => { export const AdminNotificationsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, formatNumber } = useI18n(); const { openSnackbar } = useSnackbar();
const { t, formatNumber, formatDate } = useI18n();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const canRetry = hasPermission(PERMISSIONS.NOTIFICATION_RETRY); const canRetry = hasPermission(PERMISSIONS.NOTIFICATION_RETRY);
const canUpdateTemplate = hasPermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE); const canUpdateTemplate = hasPermission(PERMISSIONS.NOTIFICATION_TEMPLATE_UPDATE);
...@@ -62,6 +64,10 @@ export const AdminNotificationsPage: React.FC = () => { ...@@ -62,6 +64,10 @@ export const AdminNotificationsPage: React.FC = () => {
const [searchDelivery, setSearchDelivery] = useState(''); const [searchDelivery, setSearchDelivery] = useState('');
const [deliveryPage, setDeliveryPage] = useState(1); const [deliveryPage, setDeliveryPage] = useState(1);
React.useEffect(() => {
setDeliveryPage(1);
}, [filterChannel, filterStatus, searchDelivery]);
// Template editing // Template editing
const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplateItem | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<AdminTemplateItem | null>(null);
const [editTitleTemplate, setEditTitleTemplate] = useState(''); const [editTitleTemplate, setEditTitleTemplate] = useState('');
...@@ -106,8 +112,15 @@ export const AdminNotificationsPage: React.FC = () => { ...@@ -106,8 +112,15 @@ export const AdminNotificationsPage: React.FC = () => {
const handleRetry = async (deliveryId: string) => { const handleRetry = async (deliveryId: string) => {
try { try {
await retryMutation.mutateAsync(deliveryId); await retryMutation.mutateAsync(deliveryId);
} catch (err: any) { openSnackbar({
alert(err?.response?.data?.message || t('admin.notifications.deliveries.retryError') || 'Thử lại gửi thông báo thất bại'); type: 'success',
text: t('admin.notifications.deliveries.retrySuccess') || 'Đang gửi lại thông báo',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.deliveries.retryError') || 'Thử lại gửi thông báo thất bại'),
});
} }
}; };
...@@ -130,8 +143,15 @@ export const AdminNotificationsPage: React.FC = () => { ...@@ -130,8 +143,15 @@ export const AdminNotificationsPage: React.FC = () => {
}, },
}); });
setSelectedTemplate(null); setSelectedTemplate(null);
} catch (err: any) { openSnackbar({
alert(err?.response?.data?.message || t('admin.notifications.templates.error') || 'Cập nhật mẫu thông báo thất bại'); type: 'success',
text: t('admin.notifications.templates.success') || 'Cập nhật mẫu thông báo thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.templates.error') || 'Cập nhật mẫu thông báo thất bại'),
});
} }
}; };
...@@ -139,11 +159,18 @@ export const AdminNotificationsPage: React.FC = () => { ...@@ -139,11 +159,18 @@ export const AdminNotificationsPage: React.FC = () => {
setChannelConfigState((prev) => ({ ...prev, [key]: value })); setChannelConfigState((prev) => ({ ...prev, [key]: value }));
try { try {
await updateChannelsMutation.mutateAsync({ [key]: value }); await updateChannelsMutation.mutateAsync({ [key]: value });
} catch (err: any) { openSnackbar({
type: 'success',
text: t('admin.notifications.channels.success') || 'Cập nhật kênh thông báo thành công',
});
} catch (err: unknown) {
if (channelsQuery.data?.data) { if (channelsQuery.data?.data) {
setChannelConfigState(channelsQuery.data.data); setChannelConfigState(channelsQuery.data.data);
} }
alert(err?.response?.data?.message || t('admin.notifications.channels.error') || 'Lưu cấu hình kênh thất bại'); openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.notifications.channels.error') || 'Lưu cấu hình kênh thất bại'),
});
} }
}; };
...@@ -358,7 +385,7 @@ export const AdminNotificationsPage: React.FC = () => { ...@@ -358,7 +385,7 @@ export const AdminNotificationsPage: React.FC = () => {
</div> </div>
<span className="text-[11px] text-clay-text-muted"> <span className="text-[11px] text-clay-text-muted">
{item.createdAt ? new Date(item.createdAt).toLocaleString() : ''} {item.createdAt ? formatDate(item.createdAt, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ''}
</span> </span>
</div> </div>
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui'; import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PermissionGate } from '@/components/shared/PermissionGate'; import { PermissionGate } from '@/components/shared/PermissionGate';
...@@ -27,25 +27,21 @@ import { ...@@ -27,25 +27,21 @@ import {
import { SettingCategory, SystemSettingItem } from '@/services/admin-settings.service'; import { SettingCategory, SystemSettingItem } from '@/services/admin-settings.service';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton'; import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from '@/lib/business-time';
import { getErrorMessage } from '@/lib/error-message';
function toDatetimeLocalValue(isoOrDateString?: string | null): string { function toDatetimeLocalValue(isoOrDateString?: string | null): string {
if (!isoOrDateString) return ''; if (!isoOrDateString) return '';
const date = new Date(isoOrDateString); return instantToBusinessDateTimeInput(isoOrDateString);
if (isNaN(date.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
} }
function toIsoValue(datetimeLocalString?: string | null): string | null { function toIsoValue(datetimeLocalString?: string | null): string | null {
if (!datetimeLocalString || !datetimeLocalString.trim()) return null; if (!datetimeLocalString || !datetimeLocalString.trim()) return null;
const date = new Date(datetimeLocalString); try {
if (isNaN(date.getTime())) return null; return businessWallTimeToIso(datetimeLocalString.trim());
return date.toISOString(); } catch {
return null;
}
} }
const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [ const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [
...@@ -59,6 +55,7 @@ const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [ ...@@ -59,6 +55,7 @@ const CATEGORIES: Array<{ key: SettingCategory | 'ALL'; labelKey: string }> = [
export const AdminSettingsPage: React.FC = () => { export const AdminSettingsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t } = useI18n(); const { t } = useI18n();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const canUpdateConfig = hasPermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE); const canUpdateConfig = hasPermission(PERMISSIONS.SYSTEM_CONFIG_UPDATE);
...@@ -151,8 +148,8 @@ export const AdminSettingsPage: React.FC = () => { ...@@ -151,8 +148,8 @@ export const AdminSettingsPage: React.FC = () => {
}, },
}); });
setSelectedSetting(null); setSelectedSetting(null);
} catch (err: any) { } catch (err: unknown) {
setEditError(err?.response?.data?.message || t('admin.settings.modal.error') || 'Cập nhật cấu hình thất bại'); setEditError(getErrorMessage(err, t('admin.settings.modal.error') || 'Cập nhật cấu hình thất bại'));
} }
}; };
...@@ -165,9 +162,15 @@ export const AdminSettingsPage: React.FC = () => { ...@@ -165,9 +162,15 @@ export const AdminSettingsPage: React.FC = () => {
endAt: toIsoValue(maintEndAt), endAt: toIsoValue(maintEndAt),
}); });
setMaintenanceDirty(false); setMaintenanceDirty(false);
alert(t('admin.settings.maintenance.success') || 'Đã cập nhật cấu hình bảo trì hệ thống thành công'); openSnackbar({
} catch (err: any) { type: 'success',
alert(err?.response?.data?.message || t('admin.settings.maintenance.error') || 'Cập nhật chế độ bảo trì thất bại'); text: t('admin.settings.maintenance.success') || 'Đã cập nhật cấu hình bảo trì hệ thống thành công',
});
} catch (err: unknown) {
openSnackbar({
type: 'error',
text: getErrorMessage(err, t('admin.settings.maintenance.error') || 'Cập nhật chế độ bảo trì thất bại'),
});
} }
}; };
......
...@@ -28,7 +28,7 @@ import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton'; ...@@ -28,7 +28,7 @@ import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const createUserSchema = z.object({ const createUserSchema = z.object({
email: z.string().email().refine((value) => value.endsWith('@gmail.com')), email: z.string().email(),
password: z.string().min(8).regex(/[a-z]/).regex(/[A-Z]/).regex(/[0-9]/).regex(/[^a-zA-Z0-9]/), password: z.string().min(8).regex(/[a-z]/).regex(/[A-Z]/).regex(/[0-9]/).regex(/[^a-zA-Z0-9]/),
roleId: z.string().min(1), roleId: z.string().min(1),
}); });
...@@ -49,6 +49,10 @@ const AdminUsersPage: React.FC = () => { ...@@ -49,6 +49,10 @@ const AdminUsersPage: React.FC = () => {
const [pendingAction, setPendingAction] = useState<PendingAction>(null); const [pendingAction, setPendingAction] = useState<PendingAction>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
React.useEffect(() => {
setPage(1);
}, [deferredSearch, roleName, activeFilter]);
const listParams = useMemo(() => ({ const listParams = useMemo(() => ({
...(deferredSearch.includes('@') ...(deferredSearch.includes('@')
? { email: deferredSearch } ? { email: deferredSearch }
......
...@@ -47,15 +47,29 @@ export const AIChatView: React.FC = () => { ...@@ -47,15 +47,29 @@ export const AIChatView: React.FC = () => {
scrollToBottom(); scrollToBottom();
}, [messages.length, messages[messages.length - 1]?.streamingText]); }, [messages.length, messages[messages.length - 1]?.streamingText]);
const streamingIntervalsRef = useRef<Record<string, ReturnType<typeof setInterval>>>({});
useEffect(() => {
return () => {
Object.values(streamingIntervalsRef.current).forEach((interval) => clearInterval(interval));
streamingIntervalsRef.current = {};
};
}, []);
// Simulated Streaming effect for AI text response // Simulated Streaming effect for AI text response
const streamAIResponse = (msgId: string, fullText: string) => { const streamAIResponse = (msgId: string, fullText: string) => {
let index = 0; let index = 0;
const speedMs = 15; // smooth typing interval const speedMs = 15; // smooth typing interval
if (streamingIntervalsRef.current[msgId]) {
clearInterval(streamingIntervalsRef.current[msgId]);
}
const interval = setInterval(() => { const interval = setInterval(() => {
index += 3; // type 3 chars per interval index += 3; // type 3 chars per interval
if (index >= fullText.length) { if (index >= fullText.length) {
clearInterval(interval); clearInterval(interval);
delete streamingIntervalsRef.current[msgId];
updateMessage(msgId, { updateMessage(msgId, {
text: fullText, text: fullText,
streamingText: undefined, streamingText: undefined,
...@@ -65,6 +79,8 @@ export const AIChatView: React.FC = () => { ...@@ -65,6 +79,8 @@ export const AIChatView: React.FC = () => {
updateMessage(msgId, { streamingText: fullText.slice(0, index) }); updateMessage(msgId, { streamingText: fullText.slice(0, index) });
} }
}, speedMs); }, speedMs);
streamingIntervalsRef.current[msgId] = interval;
}; };
const handleSend = (textToSend?: string) => { const handleSend = (textToSend?: string) => {
......
...@@ -16,27 +16,20 @@ import { useZaloLogin } from "@/hooks/use-zalo-login"; ...@@ -16,27 +16,20 @@ import { useZaloLogin } from "@/hooks/use-zalo-login";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { safeStorage } from "@/lib/storage";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail"; const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const getRememberedEmail = (): string => { const getRememberedEmail = (): string => {
try { return safeStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
return localStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
} catch {
return "";
}
}; };
const updateRememberedEmail = (email: string, shouldRemember: boolean): void => { const updateRememberedEmail = (email: string, shouldRemember: boolean): void => {
try { if (shouldRemember) {
if (shouldRemember) { safeStorage.setItem(REMEMBERED_EMAIL_KEY, email);
localStorage.setItem(REMEMBERED_EMAIL_KEY, email); } else {
} else { safeStorage.removeItem(REMEMBERED_EMAIL_KEY);
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Storage can be unavailable in restricted webviews; login should still succeed.
} }
}; };
......
...@@ -16,49 +16,10 @@ import { getCategoryDisplayName } from "@/lib/category-format"; ...@@ -16,49 +16,10 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget"; import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category"; import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time"; import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return value;
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
[integerDisplay, decimalDisplay] = trimmedValue.split(group).join("").split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
function localDate(value?: string): string { function localDate(value?: string): string {
if (!value) return todayInBusinessTime(); if (!value) return todayInBusinessTime();
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value); return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : instantToBusinessDate(value);
......
...@@ -37,7 +37,7 @@ function HomePage() { ...@@ -37,7 +37,7 @@ function HomePage() {
sortBy: "startDate", sortBy: "startDate",
order: "desc", order: "desc",
page: 1, page: 1,
limit: 100, limit: 25,
}); });
const { budgetAlertCount, hasExceededBudget } = useMemo(() => { const { budgetAlertCount, hasExceededBudget } = useMemo(() => {
...@@ -64,7 +64,7 @@ function HomePage() { ...@@ -64,7 +64,7 @@ function HomePage() {
const recurringQuery = useRecurringTransactions({ const recurringQuery = useRecurringTransactions({
isActive: true, isActive: true,
page: 1, page: 1,
limit: 100, limit: 25,
}); });
const discoveryQuery = useDiscoveredSubscriptions(); const discoveryQuery = useDiscoveredSubscriptions();
...@@ -104,7 +104,7 @@ function HomePage() { ...@@ -104,7 +104,7 @@ function HomePage() {
<Header title={t("home.header")} showBackIcon={false} /> <Header title={t("home.header")} showBackIcon={false} />
<IconGradients /> <IconGradients />
<div className="flex flex-col items-center justify-start gap-5 px-4 pt-2 pb-6"> <div className="flex flex-col items-center justify-start gap-4 pt-2 pb-6">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
<div className="relative"> <div className="relative">
<Avatar <Avatar
...@@ -120,9 +120,9 @@ function HomePage() { ...@@ -120,9 +120,9 @@ function HomePage() {
</div> </div>
{/* Text Area */} {/* Text Area */}
<div className="text-center space-y-2"> <div className="text-center space-y-1.5 max-w-sm mx-auto">
<h1 className="clay-title-h1 text-clay-primary">{t("home.greeting", { name: user?.fullName || t("common.user") })}</h1> <h1 className="clay-title-h1 text-clay-primary">{t("home.greeting", { name: user?.fullName || t("common.user") })}</h1>
<h2 className="clay-title-h3 text-clay-text">{t("home.subtitle")}</h2> <h2 className="clay-title-h3 text-clay-text [text-wrap:balance]">{t("home.subtitle")}</h2>
<p className="clay-caption max-w-xs mx-auto"> <p className="clay-caption max-w-xs mx-auto">
{t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span> {t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span>
</p> </p>
...@@ -130,7 +130,7 @@ function HomePage() { ...@@ -130,7 +130,7 @@ function HomePage() {
</div> </div>
{/* Navigation CTA */} {/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3 pb-12"> <div className="w-full max-w-sm mx-auto flex flex-col gap-3 pb-12">
<PermissionGate permission={PERMISSIONS.USER_READ}> <PermissionGate permission={PERMISSIONS.USER_READ}>
<button <button
type="button" type="button"
......
import React from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
const NotFoundPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Page className="page min-h-screen bg-clay-bg flex flex-col">
<Header title="404" showBackIcon onBackClick={() => navigate("/")} />
<div className="flex-1 flex items-center justify-center p-4">
<Card className="max-w-md w-full p-8 text-center flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-clay-lg bg-clay-warning/15 text-clay-warning flex items-center justify-center shadow-clay-pressed text-3xl font-bold font-baloo">
404
</div>
<div>
<h1 className="clay-title-h2">Không tìm thấy trang</h1>
<p className="clay-caption mt-2 text-clay-text-muted">
Đường dẫn bạn yêu cầu không tồn tại hoặc đã được di chuyển.
</p>
</div>
<Button
variant="primary"
className="mt-2 px-6"
onClick={() => navigate("/", { replace: true })}
>
Quay lại trang chủ
</Button>
</Card>
</div>
</Page>
);
};
export default NotFoundPage;
...@@ -337,6 +337,7 @@ const ProfilePage: React.FC = () => { ...@@ -337,6 +337,7 @@ const ProfilePage: React.FC = () => {
} catch (err) { } catch (err) {
// Still clear local auth state if logout call fails (e.g. server down) // Still clear local auth state if logout call fails (e.g. server down)
} finally { } finally {
queryClient.clear();
clearAuth(); clearAuth();
navigate("/login", { replace: true }); navigate("/login", { replace: true });
} }
......
...@@ -28,52 +28,7 @@ import { ...@@ -28,52 +28,7 @@ import {
} from "@/components/ui/icons"; } from "@/components/ui/icons";
import { Logo } from "@/components/logo"; import { Logo } from "@/components/logo";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return "";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
const localeNormalized = trimmedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
if (!integerDigits && decimalDisplay === undefined) return "";
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const StyleGuidePage: React.FC = () => { const StyleGuidePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
......
import React from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { DiscoveredSubscription } from "@/types/subscription";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
interface SubscriptionCardProps {
item: DiscoveredSubscription;
onConvertToReminder: (item: DiscoveredSubscription) => void;
onConvertToRecurring: (item: DiscoveredSubscription) => void;
isConverting?: boolean;
isCreatingRecurring?: boolean;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
item,
onConvertToReminder,
onConvertToRecurring,
isConverting,
isCreatingRecurring,
}) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
const latestAmount = parseFloat(item.latestAmount);
return (
<Card className="space-y-3 p-4 shadow-clay-raised">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-clay-text text-sm font-baloo">{item.merchantName}</h4>
<Badge type="primary" className="text-[10px] px-2 py-0.5">
{t(`subscriptions.freq_${item.frequency}`)}
</Badge>
</div>
<p className="text-xs text-clay-text-muted mt-0.5 font-medium">
{getCategoryDisplayName({ name: item.categoryName }, t)}{item.occurrenceCount} {t("subscriptions.occurrences")}
</p>
</div>
<div className="text-right">
<p className="font-bold text-clay-primary text-sm">
{formatCurrency(latestAmount, item.currency)}
</p>
<span className="text-[10px] text-clay-text-muted block font-medium">
{t("subscriptions.confidence")}: {(item.confidenceScore * 100).toFixed(0)}%
</span>
</div>
</div>
{/* Price Drift Alert */}
{item.isPriceDrift && (
<div className="bg-clay-warning-soft border border-clay-warning/40 px-3 py-1.5 rounded-clay-sm flex items-center justify-between text-xs shadow-clay-pressed">
<span className="text-clay-text font-bold">
⚠️ {t("subscriptions.priceHikeAlert")}
</span>
<span className="font-bold text-clay-warning">
+{formatNumber(item.priceDriftPercentage ?? 0, { maximumFractionDigits: 1 })}%
</span>
</div>
)}
{/* Next Expected Billing Date */}
<div className="flex items-center justify-between pt-2 border-t border-clay-border/40 text-xs">
<span className="text-clay-text-muted font-medium">
{t("subscriptions.nextBilling")}: {" "}
<b className="text-clay-text font-bold">
{formatBusinessDate(item.nextExpectedAt, intlLocale, { day: "2-digit", month: "2-digit", year: "numeric" })}
</b>
</span>
{item.isLinkedToReminder ? (
<Badge type="income" className="text-[10px]">
{t("subscriptions.trackedInReminders")}
</Badge>
) : (
<Button
variant="secondary"
onClick={() => onConvertToReminder(item)}
disabled={isConverting}
className="text-[11px] py-1 px-3"
>
{isConverting ? t("common.processing") : t("subscriptions.convertToReminderBtn")}
</Button>
)}
</div>
<Button
variant="primary"
fullWidth
onClick={() => onConvertToRecurring(item)}
disabled={isCreatingRecurring}
className="text-xs py-2"
>
{isCreatingRecurring ? t("common.processing") : t("recurringTransactions.subscription.button")}
</Button>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const SubscriptionSkeleton: React.FC = () => (
<div className="space-y-3 animate-pulse" aria-hidden="true">
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
);
This diff is collapsed.
...@@ -15,58 +15,10 @@ import { getCategoryDisplayName } from "@/lib/category-format"; ...@@ -15,58 +15,10 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category"; import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction"; import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time"; import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) {
return value;
}
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const { group, decimal } = getNumberSeparators(locale);
const unsignedValue = trimmedValue;
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(decimal)) {
const localeNormalized = unsignedValue.split(group).join("");
[integerDisplay, decimalDisplay] = localeNormalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(unsignedValue)) {
const separatorIndex = Math.max(unsignedValue.lastIndexOf("."), unsignedValue.lastIndexOf(","));
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
} else {
integerDisplay = unsignedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const createTransactionSchema = (t: TranslationFunction) => z.object({ const createTransactionSchema = (t: TranslationFunction) => z.object({
amount: z.string().trim().min(1, t("validation.transactionAmountRequired")) amount: z.string().trim().min(1, t("validation.transactionAmountRequired"))
.regex(amountPattern, t("validation.transactionAmountInvalid")) .regex(amountPattern, t("validation.transactionAmountInvalid"))
......
This diff is collapsed.
...@@ -15,51 +15,10 @@ import { formatWalletBalance } from "@/lib/wallet-format"; ...@@ -15,51 +15,10 @@ import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer"; import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet"; import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time"; import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const separators = new Intl.NumberFormat(locale).format(1234.5).match(/[^\d]/g) || [];
return {
group: separators[0] || ",",
decimal: separators[separators.length - 1] || ".",
};
}
function formatAmountInput(value: string, locale: string): string {
if (!value) return "";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
function parseAmountInput(value: string, locale: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const { group, decimal } = getNumberSeparators(locale);
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
if (trimmedValue.includes(decimal)) {
const normalized = trimmedValue.split(group).join("");
[integerDisplay, decimalDisplay] = normalized.split(decimal, 2);
} else if (/^\d+[.,]\d{0,2}$/.test(trimmedValue)) {
const separatorIndex = Math.max(trimmedValue.lastIndexOf("."), trimmedValue.lastIndexOf(","));
integerDisplay = trimmedValue.slice(0, separatorIndex);
decimalDisplay = trimmedValue.slice(separatorIndex + 1);
} else {
integerDisplay = trimmedValue.split(group).join("");
}
const integerDigits = integerDisplay.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || "0";
const decimalDigits = decimalDisplay?.replace(/\D/g, "").slice(0, 2);
return `${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
function hasSufficientBalance(amount: string, balance: string): boolean { function hasSufficientBalance(amount: string, balance: string): boolean {
if (balance.trim().startsWith("-")) return false; if (balance.trim().startsWith("-")) return false;
......
...@@ -10,17 +10,10 @@ import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format"; ...@@ -10,17 +10,10 @@ import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format";
import { Wallet, WalletInput } from "@/types/wallet"; import { Wallet, WalletInput } from "@/types/wallet";
import { WalletArtwork } from "@/components/shared/WalletArtwork"; import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getNumberSeparators } from "@/lib/money-input";
const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatBalanceInput(value: string, locale: string, currency?: string): string { function formatBalanceInput(value: string, locale: string, currency?: string): string {
if (!value || value === "-") { if (!value || value === "-") {
return value; return value;
......
...@@ -59,7 +59,7 @@ export const adminSettingsService = { ...@@ -59,7 +59,7 @@ export const adminSettingsService = {
return response.data; return response.data;
}, },
async updateSetting(key: string, data: { value: any; description?: string }): Promise<ApiResponse<SystemSettingItem>> { async updateSetting(key: string, data: { value: unknown; description?: string }): Promise<ApiResponse<SystemSettingItem>> {
const response = await apiClient.patch(`/admin/settings/${key}`, data); const response = await apiClient.patch(`/admin/settings/${key}`, data);
return response.data; return response.data;
}, },
......
import axios from "axios";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { import {
AIChatInput, AIChatInput,
...@@ -29,14 +30,14 @@ export class AIAssistantError extends Error { ...@@ -29,14 +30,14 @@ export class AIAssistantError extends Error {
} }
} }
function handleAIError(error: any): never { function handleAIError(error: unknown): never {
if (error.response) { if (axios.isAxiosError(error) && error.response) {
const status = error.response.status; const status = error.response.status;
const data = error.response.data; const data = error.response.data as { message?: string; code?: string } | undefined;
const headers = error.response.headers; const headers = error.response.headers;
if (status === 429) { if (status === 429) {
const retryHeader = headers?.["retry-after"]; const retryHeader = headers?.["retry-after"] as string | undefined;
const retryAfter = retryHeader ? parseInt(retryHeader, 10) : 30; const retryAfter = retryHeader ? parseInt(retryHeader, 10) : 30;
throw new AIAssistantError( throw new AIAssistantError(
data?.message || "AI request limit exceeded, please try again later", data?.message || "AI request limit exceeded, please try again later",
...@@ -53,7 +54,11 @@ function handleAIError(error: any): never { ...@@ -53,7 +54,11 @@ function handleAIError(error: any): never {
); );
} }
throw new AIAssistantError(error.message || "Network error while calling AI Assistant"); if (error instanceof Error) {
throw new AIAssistantError(error.message || "Network error while calling AI Assistant");
}
throw new AIAssistantError("Network error while calling AI Assistant");
} }
export const aiAssistantService = { export const aiAssistantService = {
......
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { ApiResponse, LoginRequest, User, Session, UpdateProfileRequest, UpdateAvatarRequest, SessionQuery, SessionsResponse } from "@/types/auth"; import {
ApiResponse,
LoginRequest,
RegisterRequest,
ResetPasswordRequest,
Session,
SessionQuery,
SessionsResponse,
UpdateAvatarRequest,
UpdatePasswordRequest,
UpdateProfileRequest,
User,
} from "@/types/auth";
export const authService = { export const authService = {
async register(data: any): Promise<ApiResponse> { async register(data: RegisterRequest): Promise<ApiResponse> {
const response = await apiClient.post("/auth/register", data); const response = await apiClient.post("/auth/register", data);
return response.data; return response.data;
}, },
...@@ -42,7 +54,7 @@ export const authService = { ...@@ -42,7 +54,7 @@ export const authService = {
return response.data; return response.data;
}, },
async updatePassword(data: any): Promise<ApiResponse> { async updatePassword(data: UpdatePasswordRequest): Promise<ApiResponse> {
const response = await apiClient.put("/auth/password", data); const response = await apiClient.put("/auth/password", data);
return response.data; return response.data;
}, },
...@@ -52,7 +64,7 @@ export const authService = { ...@@ -52,7 +64,7 @@ export const authService = {
return response.data; return response.data;
}, },
async resetPassword(data: any): Promise<ApiResponse> { async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse> {
const response = await apiClient.post("/auth/reset-password", data); const response = await apiClient.post("/auth/reset-password", data);
return response.data; return response.data;
}, },
......
import { create } from "zustand"; import { create } from "zustand";
import { User } from "@/types/auth"; import { User } from "@/types/auth";
import { useAIChatStore } from "@/stores/ai-chat-store"; import { useAIChatStore } from "@/stores/ai-chat-store";
import { queryClient } from "@/lib/query-client";
import { safeStorage } from "@/lib/storage";
interface AuthState { interface AuthState {
user: User | null; user: User | null;
...@@ -15,9 +17,9 @@ interface AuthState { ...@@ -15,9 +17,9 @@ interface AuthState {
} }
export const useAuthStore = create<AuthState>((set) => { export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from localStorage // Pre-load tokens from safeStorage
const accessToken = localStorage.getItem("accessToken"); const accessToken = safeStorage.getItem("accessToken");
const refreshToken = localStorage.getItem("refreshToken"); const refreshToken = safeStorage.getItem("refreshToken");
return { return {
user: null, user: null,
...@@ -26,8 +28,8 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -26,8 +28,8 @@ export const useAuthStore = create<AuthState>((set) => {
isAuthenticated: !!accessToken, isAuthenticated: !!accessToken,
isInitialized: false, isInitialized: false,
setAuth: (user, accessToken, refreshToken) => { setAuth: (user, accessToken, refreshToken) => {
localStorage.setItem("accessToken", accessToken); safeStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken); safeStorage.setItem("refreshToken", refreshToken);
set({ set({
user, user,
accessToken, accessToken,
...@@ -36,9 +38,10 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -36,9 +38,10 @@ export const useAuthStore = create<AuthState>((set) => {
}); });
}, },
clearAuth: () => { clearAuth: () => {
localStorage.removeItem("accessToken"); safeStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken"); safeStorage.removeItem("refreshToken");
useAIChatStore.getState().clearMessages(); useAIChatStore.getState().clearMessages();
queryClient.clear();
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
......
import { create } from "zustand"; import { create } from "zustand";
import { safeStorage } from "@/lib/storage";
export type ThemeMode = "light" | "dark"; export type ThemeMode = "light" | "dark";
...@@ -16,13 +17,9 @@ function getInitialTheme(): ThemeMode { ...@@ -16,13 +17,9 @@ function getInitialTheme(): ThemeMode {
} }
} }
try { const storedTheme = safeStorage.getItem(THEME_STORAGE_KEY);
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY); if (isThemeMode(storedTheme)) {
if (isThemeMode(storedTheme)) { return storedTheme;
return storedTheme;
}
} catch {
// Storage may be unavailable in restricted webviews.
} }
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) { if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
...@@ -56,12 +53,7 @@ export const useThemeStore = create<ThemeState>((set, get) => ({ ...@@ -56,12 +53,7 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
theme: initialTheme, theme: initialTheme,
setTheme: (theme) => { setTheme: (theme) => {
applyTheme(theme); applyTheme(theme);
safeStorage.setItem(THEME_STORAGE_KEY, theme);
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Keep the selected theme for this session even when persistence is blocked.
}
set({ theme }); set({ theme });
}, },
......
import { PaginationMeta } from "./wallet";
export interface Permission { export interface Permission {
id: string; id: string;
name: string; name: string;
...@@ -97,7 +99,23 @@ export interface UpdateAvatarRequest { ...@@ -97,7 +99,23 @@ export interface UpdateAvatarRequest {
avatarPositionY?: number; avatarPositionY?: number;
} }
import { PaginationMeta } from "./wallet"; export interface RegisterRequest {
email: string;
password: string;
fullName?: string;
phoneNumber?: string;
}
export interface UpdatePasswordRequest {
oldPassword?: string;
currentPassword?: string;
newPassword: string;
}
export interface ResetPasswordRequest {
token: string;
newPassword: string;
}
export interface SessionQuery { export interface SessionQuery {
page?: number; page?: number;
...@@ -116,10 +134,10 @@ export interface SessionsResponse extends ApiResponse<Session[]> { ...@@ -116,10 +134,10 @@ export interface SessionsResponse extends ApiResponse<Session[]> {
meta: PaginationMeta; meta: PaginationMeta;
} }
export interface ApiResponse<T = any> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;
message: string; message: string;
data: T; data: T;
errors?: any[] | null; errors?: unknown[] | null;
} }
import { TransactionType } from "./category"; import { TransactionType } from "./category";
export { TransactionType } from "./category";
export type TransactionSortField = export type TransactionSortField =
| "amount" | "amount"
......
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