Commit 20366e62 authored by ThinhNC's avatar ThinhNC

feat(ui): add dark theme and formatted wallet balance input

parent e3513736
......@@ -10,6 +10,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Bo góc tối thiểu 16px (`rounded-clay-sm`), tiêu chuẩn 24px (`rounded-clay`), và lớn 32px (`rounded-clay-lg`).
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng lên `data-theme``zaui-theme`, đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
- **Quản lý Routing**: Sử dụng cấu hình router của ZMP UI / React Router tích hợp bên trong template để dẫn hướng giữa các màn hình nghiệp vụ và `/style-guide`.
- **Khoảng trống cho header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
......
......@@ -18,6 +18,25 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
(() => {
try {
const savedTheme = localStorage.getItem("finwise.theme");
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = savedTheme === "light" || savedTheme === "dark"
? savedTheme
: prefersDark ? "dark" : "light";
document.documentElement.dataset.theme = theme;
document.documentElement.setAttribute("zaui-theme", theme);
document.documentElement.style.colorScheme = theme;
const themeMeta = document.querySelector('meta[name="theme-color"]');
if (themeMeta) themeMeta.setAttribute("content", theme === "dark" ? "#181726" : "#F3F0FA");
} catch {
document.documentElement.dataset.theme = "light";
document.documentElement.setAttribute("zaui-theme", "light");
}
})();
</script>
</head>
<body>
<div id="app"></div>
......
import React, { useEffect } from "react";
import { getSystemInfo } from "zmp-sdk";
import {
AnimationRoutes,
App,
......@@ -7,12 +6,13 @@ import {
SnackbarProvider,
ZMPRouter,
} from "zmp-ui";
import { AppProps } from "zmp-ui/app";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { AuthGuard } from "@/components/shared/AuthGuard";
import { ThemeControl } from "@/components/shared/ThemeControl";
import { applyTheme, useThemeStore } from "@/stores/theme-store";
import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide";
......@@ -51,10 +51,17 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
};
const Layout = () => {
const theme = useThemeStore((state) => state.theme);
useEffect(() => {
applyTheme(theme);
}, [theme]);
return (
<App theme={getSystemInfo().zaloTheme as AppProps["theme"]}>
<App theme={theme}>
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<ThemeControl />
<ZMPRouter>
<AuthInitializer>
<AnimationRoutes>
......
import React from "react";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useThemeStore } from "@/stores/theme-store";
export const ThemeControl: React.FC = () => {
const { theme, setTheme } = useThemeStore();
return (
<div
className="fixed z-[1001] flex items-center"
style={{
top: "calc(var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px)",
right: "calc(env(safe-area-inset-right, 0px) + 96px)",
}}
>
<ThemeToggle theme={theme} onChange={setTheme} variant="compact" />
</div>
);
};
......@@ -36,7 +36,7 @@ export const WalletArtwork: React.FC<WalletArtworkProps> = ({
archived = false,
}) => (
<div
className={`${dimensions[size]} shrink-0 flex items-center justify-center text-white shadow-clay-raised border-2 border-white/50 transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-60" : ""}`}
className={`${dimensions[size]} shrink-0 flex items-center justify-center text-clay-on-primary shadow-clay-raised border-2 border-clay-highlight/50 transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-60" : ""}`}
style={{ backgroundColor: color || "#8B7CF6" }}
aria-hidden="true"
>
......
......@@ -27,7 +27,7 @@ export const Avatar: React.FC<AvatarProps> = ({
return (
<div
className={`
rounded-full bg-clay-surface border-white shadow-clay-raised overflow-hidden inline-block flex-shrink-0
rounded-full bg-clay-surface border-clay-highlight shadow-clay-raised overflow-hidden inline-block flex-shrink-0
${sizeStyles[size]}
${className}
`}
......
......@@ -12,13 +12,12 @@ export const Badge: React.FC<BadgeProps> = ({
className = "",
...props
}) => {
// Pastel backgrounds and corresponding dark text colors
const typeStyles = {
primary: "bg-[#EBE9FE] text-[#5B21B6] border border-[#D9D6FE]",
income: "bg-[#E6FDF5] text-[#059669] border border-[#C6F6E5]",
expense: "bg-[#FFF1F2] text-[#E11D48] border border-[#FFE4E6]",
warning: "bg-[#FEF3C7] text-[#D97706] border border-[#FDE68A]",
info: "bg-[#EFF6FF] text-[#2563EB] border border-[#DBEAFE]",
primary: "bg-clay-badge-primary-bg text-clay-badge-primary-text border border-clay-badge-primary-border",
income: "bg-clay-badge-income-bg text-clay-badge-income-text border border-clay-badge-income-border",
expense: "bg-clay-badge-expense-bg text-clay-badge-expense-text border border-clay-badge-expense-border",
warning: "bg-clay-badge-warning-bg text-clay-badge-warning-text border border-clay-badge-warning-border",
info: "bg-clay-badge-info-bg text-clay-badge-info-text border border-clay-badge-info-border",
};
return (
......
......@@ -17,7 +17,7 @@ export const Button: React.FC<ButtonProps> = ({
}) => {
// Styles for different variants
const variantStyles = {
primary: "bg-clay-primary text-white 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]",
ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]",
};
......@@ -38,7 +38,7 @@ export const Button: React.FC<ButtonProps> = ({
<button
className={`
inline-flex items-center justify-center font-baloo font-semibold text-lg px-6 py-2.5
transition-all duration-150 ease-in-out focus:outline-none select-none
transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 select-none
${variantStyles[variant]}
${shapeStyles[shape]}
${disabledStyles}
......
......@@ -14,7 +14,7 @@ export const Card: React.FC<CardProps> = ({
<div
className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6
transition-all duration-200 ease-in-out border border-white/40
transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className}
`}
......
......@@ -14,11 +14,11 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({
...props
}) => {
const typeStyles = {
primary: "bg-[#EBE9FE] text-[#8B7CF6] border border-[#D9D6FE]/50",
income: "bg-[#E6FDF5] text-[#34D399] border border-[#C6F6E5]/50",
expense: "bg-[#FFF1F2] text-[#FB7185] border border-[#FFE4E6]/50",
warning: "bg-[#FEF3C7] text-[#FBBF24] border border-[#FDE68A]/50",
info: "bg-[#EFF6FF] text-[#60A5FA] border border-[#DBEAFE]/50",
primary: "bg-clay-badge-primary-bg text-clay-primary border border-clay-badge-primary-border/50",
income: "bg-clay-badge-income-bg text-clay-income border border-clay-badge-income-border/50",
expense: "bg-clay-badge-expense-bg text-clay-expense border border-clay-badge-expense-border/50",
warning: "bg-clay-badge-warning-bg text-clay-warning border border-clay-badge-warning-border/50",
info: "bg-clay-badge-info-bg text-clay-info border border-clay-badge-info-border/50",
};
const sizeStyles = {
......@@ -30,7 +30,7 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({
return (
<div
className={`
inline-flex items-center justify-center shadow-clay-raised border-white
inline-flex items-center justify-center shadow-clay-raised
transition-all duration-200 ease-in-out hover:scale-105
${typeStyles[type]}
${sizeStyles[size]}
......
......@@ -35,7 +35,7 @@ export const Modal: React.FC<ModalProps> = ({
<div className="fixed inset-0 z-[999] flex items-end sm:items-center justify-center p-0 sm:p-4 animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-[#2D2A45]/45 backdrop-blur-[6px] transition-opacity"
className="absolute inset-0 bg-clay-overlay/55 backdrop-blur-[6px] transition-opacity"
onClick={onClose}
/>
......@@ -43,8 +43,7 @@ export const Modal: React.FC<ModalProps> = ({
<div
className="
relative w-full max-w-lg bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg
shadow-[15px_15px_30px_rgba(163,150,208,0.55),-10px_-10px_20px_rgba(255,255,255,0.9)]
border-t border-x sm:border border-white/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
transform transition-all duration-300 ease-out translate-y-0 sm:scale-100
animate-slide-up
"
......
......@@ -19,11 +19,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
// Gradient styles corresponding to semantic types
const gradientStyles = {
primary: "bg-gradient-to-r from-[#A78BFA] to-[#8B7CF6] border-r border-white/30",
income: "bg-gradient-to-r from-[#6EE7B7] to-[#34D399] border-r border-white/30",
expense: "bg-gradient-to-r from-[#FDA4AF] to-[#FB7185] border-r border-white/30",
warning: "bg-gradient-to-r from-[#FDE047] to-[#FBBF24] border-r border-white/30",
info: "bg-gradient-to-r from-[#93C5FD] to-[#60A5FA] border-r border-white/30",
primary: "bg-gradient-to-r from-clay-primary-soft to-clay-primary border-r border-clay-highlight/30",
income: "bg-gradient-to-r from-clay-income-soft to-clay-income border-r border-clay-highlight/30",
expense: "bg-gradient-to-r from-clay-expense-soft to-clay-expense border-r border-clay-highlight/30",
warning: "bg-gradient-to-r from-clay-warning-soft to-clay-warning border-r border-clay-highlight/30",
info: "bg-gradient-to-r from-clay-info-soft to-clay-info border-r border-clay-highlight/30",
};
return (
......@@ -36,7 +36,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
>
<div
className={`
h-full rounded-full transition-all duration-300 ease-out shadow-[inset_-2px_-2px_4px_rgba(0,0,0,0.15)]
h-full rounded-full transition-all duration-300 ease-out shadow-clay-progress
${gradientStyles[type]}
`}
style={{ width: `${percentage}%` }}
......
......@@ -36,7 +36,7 @@ export const Tabs: React.FC<TabsProps> = ({
transition-all duration-200 ease-in-out focus:outline-none z-10
${
isActive
? "bg-clay-primary text-white shadow-clay-raised transform translate-y-0 border border-clay-primary-dark/20"
? "bg-clay-primary text-clay-on-primary shadow-clay-raised transform translate-y-0 border border-clay-primary-dark/20"
: "text-clay-text-muted hover:text-clay-text bg-transparent"
}
`}
......
import React from "react";
import { ThemeMode } from "@/stores/theme-store";
export interface ThemeToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
theme: ThemeMode;
onChange: (theme: ThemeMode) => void;
variant?: "switch" | "compact";
}
const SunIcon: React.FC = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="3.5" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" />
</svg>
);
const MoonIcon: React.FC = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5 8.5 8.5 0 1 0 20.5 14.5Z" />
</svg>
);
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
theme,
onChange,
variant = "switch",
className = "",
onClick,
...props
}) => {
const isDark = theme === "dark";
const nextThemeLabel = isDark ? "giao diện sáng" : "giao diện tối";
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(isDark ? "light" : "dark");
onClick?.(event);
};
if (variant === "compact") {
return (
<button
type="button"
role="switch"
aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`}
title={`Chuyển sang ${nextThemeLabel}`}
{...props}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
onClick={handleClick}
>
{isDark ? <MoonIcon /> : <SunIcon />}
</button>
);
}
return (
<button
type="button"
role="switch"
aria-checked={isDark}
aria-label={`Chuyển sang ${nextThemeLabel}`}
title={`Chuyển sang ${nextThemeLabel}`}
className={`relative inline-flex h-8 w-14 flex-none items-center rounded-full border border-clay-border bg-clay-surface p-1 text-clay-text-muted shadow-clay-pressed transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
{...props}
onClick={handleClick}
>
<span className="absolute left-1.5 text-clay-warning"><SunIcon /></span>
<span className="absolute right-1.5 text-clay-info"><MoonIcon /></span>
<span
aria-hidden="true"
className={`relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out ${isDark ? "translate-x-6" : "translate-x-0"}`}
>
{isDark ? <MoonIcon /> : <SunIcon />}
</span>
</button>
);
};
This diff is collapsed.
:root,
:root[data-theme="light"] {
color-scheme: light;
--color-clay-bg: 243 240 250;
--color-clay-surface: 237 233 247;
--color-clay-primary: 139 124 246;
--color-clay-primary-soft: 167 139 250;
--color-clay-primary-dark: 109 91 208;
--color-clay-income: 52 211 153;
--color-clay-income-soft: 110 231 183;
--color-clay-expense: 251 113 133;
--color-clay-expense-soft: 253 164 175;
--color-clay-warning: 251 191 36;
--color-clay-warning-soft: 253 224 71;
--color-clay-info: 96 165 250;
--color-clay-info-soft: 147 197 253;
--color-clay-text: 45 42 69;
--color-clay-text-muted: 112 105 139;
--color-clay-border: 208 201 229;
--color-clay-highlight: 255 255 255;
--color-clay-on-primary: 255 255 255;
--color-clay-on-status: 45 42 69;
--color-clay-overlay: 45 42 69;
--color-clay-badge-primary-bg: 235 233 254;
--color-clay-badge-primary-text: 91 33 182;
--color-clay-badge-primary-border: 217 214 254;
--color-clay-badge-income-bg: 230 253 245;
--color-clay-badge-income-text: 5 150 105;
--color-clay-badge-income-border: 198 246 229;
--color-clay-badge-expense-bg: 255 241 242;
--color-clay-badge-expense-text: 225 29 72;
--color-clay-badge-expense-border: 255 228 230;
--color-clay-badge-warning-bg: 254 243 199;
--color-clay-badge-warning-text: 180 83 9;
--color-clay-badge-warning-border: 253 230 138;
--color-clay-badge-info-bg: 239 246 255;
--color-clay-badge-info-text: 37 99 235;
--color-clay-badge-info-border: 219 234 254;
--shadow-clay-raised: 8px 8px 16px rgb(163 150 208 / 0.45), -6px -6px 14px rgb(255 255 255 / 0.85);
--shadow-clay-hover: 10px 10px 20px rgb(163 150 208 / 0.45), -8px -8px 16px rgb(255 255 255 / 0.85);
--shadow-clay-pressed: inset 4px 4px 8px rgb(163 150 208 / 0.45), inset -4px -4px 8px rgb(255 255 255 / 0.85);
--shadow-clay-modal: 15px 15px 30px rgb(163 150 208 / 0.55), -10px -10px 20px rgb(255 255 255 / 0.9);
--shadow-clay-progress: inset -2px -2px 4px rgb(45 42 69 / 0.15);
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-clay-bg: 24 23 38;
--color-clay-surface: 34 32 53;
--color-clay-primary: 166 151 255;
--color-clay-primary-soft: 196 181 253;
--color-clay-primary-dark: 196 181 253;
--color-clay-income: 94 230 178;
--color-clay-income-soft: 134 239 199;
--color-clay-expense: 255 141 161;
--color-clay-expense-soft: 255 181 193;
--color-clay-warning: 255 209 102;
--color-clay-warning-soft: 255 225 145;
--color-clay-info: 125 183 255;
--color-clay-info-soft: 165 205 255;
--color-clay-text: 244 241 255;
--color-clay-text-muted: 183 176 207;
--color-clay-border: 80 74 109;
--color-clay-highlight: 90 83 120;
--color-clay-on-primary: 28 24 48;
--color-clay-on-status: 28 24 48;
--color-clay-overlay: 7 6 14;
--color-clay-badge-primary-bg: 58 49 91;
--color-clay-badge-primary-text: 211 201 255;
--color-clay-badge-primary-border: 104 89 156;
--color-clay-badge-income-bg: 24 67 56;
--color-clay-badge-income-text: 133 244 203;
--color-clay-badge-income-border: 44 113 92;
--color-clay-badge-expense-bg: 82 37 51;
--color-clay-badge-expense-text: 255 181 193;
--color-clay-badge-expense-border: 132 62 80;
--color-clay-badge-warning-bg: 78 60 26;
--color-clay-badge-warning-text: 255 222 139;
--color-clay-badge-warning-border: 125 95 38;
--color-clay-badge-info-bg: 31 58 91;
--color-clay-badge-info-text: 173 211 255;
--color-clay-badge-info-border: 53 91 137;
--shadow-clay-raised: 8px 8px 16px rgb(5 4 14 / 0.68), -6px -6px 14px rgb(78 72 105 / 0.35);
--shadow-clay-hover: 10px 10px 20px rgb(5 4 14 / 0.75), -8px -8px 16px rgb(83 76 112 / 0.4);
--shadow-clay-pressed: inset 4px 4px 8px rgb(5 4 14 / 0.72), inset -4px -4px 8px rgb(82 75 111 / 0.34);
--shadow-clay-modal: 15px 15px 30px rgb(4 3 11 / 0.78), -10px -10px 20px rgb(82 75 111 / 0.32);
--shadow-clay-progress: inset -2px -2px 4px rgb(5 4 14 / 0.45);
}
html,
body,
#app {
min-height: 100%;
background-color: rgb(var(--color-clay-bg));
}
body {
font-family: 'Nunito', sans-serif;
background-color: #F3F0FA; /* clay-bg */
color: #2D2A45; /* clay-text */
margin: 0;
color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased;
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
}
/* Custom style override for ZMP Page containers */
.page {
padding: calc(var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px) 16px 96px;
background-color: #F3F0FA; /* clay-bg */
background-color: rgb(var(--color-clay-bg));
min-height: 100vh;
color: rgb(var(--color-clay-text));
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
}
.clay-title-h1 {
......@@ -17,7 +116,7 @@ body {
font-weight: 700;
font-size: 32px;
line-height: 1.2;
color: #2D2A45; /* clay-text */
color: rgb(var(--color-clay-text));
}
.clay-title-h2 {
......@@ -25,7 +124,7 @@ body {
font-weight: 600;
font-size: 24px;
line-height: 1.3;
color: #2D2A45;
color: rgb(var(--color-clay-text));
}
.clay-title-h3 {
......@@ -33,7 +132,7 @@ body {
font-weight: 600;
font-size: 18px;
line-height: 1.4;
color: #2D2A45;
color: rgb(var(--color-clay-text));
}
.clay-body {
......@@ -41,7 +140,7 @@ body {
font-weight: 400;
font-size: 16px;
line-height: 1.5;
color: #2D2A45;
color: rgb(var(--color-clay-text));
}
.clay-caption {
......@@ -49,7 +148,7 @@ body {
font-weight: 500;
font-size: 13px;
line-height: 1.4;
color: #8B85A6; /* clay-text-muted */
color: rgb(var(--color-clay-text-muted));
}
/* Custom scrollbars inside Claymorphism components */
......@@ -61,9 +160,22 @@ body {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(163, 150, 208, 0.3);
background: rgb(var(--color-clay-text-muted) / 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(163, 150, 208, 0.5);
background: rgb(var(--color-clay-text-muted) / 0.5);
}
/* Keep ZMP navigation surfaces aligned with the active FinWise theme. */
.zaui-header,
.zaui-page {
background-color: rgb(var(--color-clay-bg));
color: rgb(var(--color-clay-text));
transition: background-color 200ms ease-in-out, color 200ms ease-in-out;
}
.zaui-header .zaui-header-title,
.zaui-header .zaui-header-back-icon {
color: rgb(var(--color-clay-text));
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*,
::before,
::after {
border-color: rgb(var(--color-clay-border));
}
}
......@@ -68,7 +68,7 @@ const ForgotPasswordPage: React.FC = () => {
{isSent ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-info/20 text-clay-info rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<div className="w-16 h-16 bg-clay-info/20 text-clay-info rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-clay-highlight">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
......@@ -97,7 +97,7 @@ const ForgotPasswordPage: React.FC = () => {
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang gửi...</span>
</div>
) : (
......
......@@ -159,7 +159,7 @@ const LoginPage: React.FC = () => {
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng nhập...</span>
</div>
) : (
......
......@@ -93,7 +93,7 @@ const RegisterPage: React.FC = () => {
{isRegistered ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-income/20 text-clay-income rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<div className="w-16 h-16 bg-clay-income/20 text-clay-income rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-clay-highlight">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 19v-8.93a2 2 0 01.89-1.664l8-4.666a2 2 0 012.22 0l8 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-2.25-1.5a2 2 0 00-2.5 0l-2.25 1.5" />
</svg>
......@@ -149,7 +149,7 @@ const RegisterPage: React.FC = () => {
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng ký...</span>
</div>
) : (
......
......@@ -114,7 +114,7 @@ const ResetPasswordPage: React.FC = () => {
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>Đang xử lý...</span>
</div>
) : (
......
......@@ -25,7 +25,7 @@ function HomePage() {
positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover"
/>
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white">
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-clay-on-primary p-2 rounded-full shadow-clay-raised border border-clay-highlight">
<AIAssistantIcon size={20} />
</div>
</div>
......
......@@ -228,7 +228,7 @@ export const AvatarEditorModal: React.FC<AvatarEditorModalProps> = ({
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
className={`h-56 w-56 touch-none select-none overflow-hidden rounded-full border-4 border-white bg-clay-bg shadow-clay-pressed transition-all duration-200 ease-in-out ${canAdjustHorizontal || canAdjustVertical ? "cursor-grab active:cursor-grabbing" : "cursor-not-allowed"}`}
className={`h-56 w-56 touch-none select-none overflow-hidden rounded-full border-4 border-clay-highlight bg-clay-bg shadow-clay-pressed transition-all duration-200 ease-in-out ${canAdjustHorizontal || canAdjustVertical ? "cursor-grab active:cursor-grabbing" : "cursor-not-allowed"}`}
>
{imageUrl ? (
<img
......
......@@ -11,7 +11,9 @@ import { Input } from "@/components/ui/Input";
import { Avatar } from "@/components/ui/Avatar";
import { Tabs } from "@/components/ui/Tabs";
import { Badge } from "@/components/ui/Badge";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { IconGradients } from "@/components/ui/icons";
......@@ -58,6 +60,7 @@ const ProfilePage: React.FC = () => {
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const [activeTab, setActiveTab] = useState("profile");
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = useState(false);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
......@@ -347,7 +350,7 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-white/50">
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/50">
<button
type="button"
aria-label="Chỉnh ảnh đại diện"
......@@ -361,7 +364,7 @@ const ProfilePage: React.FC = () => {
positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover"
/>
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-clay-primary text-white shadow-clay-raised transition-all duration-200 ease-in-out group-active:shadow-clay-pressed">
<span className="absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full border-2 border-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out group-active:shadow-clay-pressed">
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4 20 9.5 9 20H4v-5Z" />
<path d="m12.5 6 5.5 5.5" />
......@@ -378,6 +381,14 @@ const ProfilePage: React.FC = () => {
</div>
</Card>
<Card className="flex items-center justify-between gap-4 p-4">
<div className="min-w-0">
<h3 className="clay-title-h3">Giao diện</h3>
<p className="clay-caption">{theme === "dark" ? "Chế độ tối" : "Chế độ sáng"} · lựa chọn được lưu trên thiết bị</p>
</div>
<ThemeToggle theme={theme} onChange={setTheme} />
</Card>
{/* Navigation Tabs */}
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
......
......@@ -59,9 +59,9 @@ const StyleGuidePage: React.FC = () => {
{/* Intro */}
<div className="text-center px-4">
<h1 className="clay-title-h1 text-clay-primary">Claymorphism System</h1>
<h1 className="clay-title-h1 text-clay-primary">Claymorphism Theme System</h1>
<p className="clay-caption mt-1">
Bộ khung thiết kế 3D đất sét mềm mại phục vụ "Sổ tay Chi tiêu & Báo cáo Tài chính"
Design tokens thích ứng Light/Dark cho giao diện đất sét mềm mại của FinWise
</p>
</div>
......@@ -97,15 +97,16 @@ const StyleGuidePage: React.FC = () => {
{/* Color Palette */}
<div>
<h3 className="clay-title-h3 text-clay-text/60 mb-3">Màu sắc Semantic</h3>
<div className="grid grid-cols-2 gap-3 text-xs font-semibold text-white">
<div className="bg-clay-bg text-clay-text p-3 rounded-clay-sm border border-clay-text/10">Nền App: #F3F0FA</div>
<div className="bg-clay-surface text-clay-text p-3 rounded-clay-sm border border-white/60 shadow-clay-pressed">Surface: #EDE9F7</div>
<div className="bg-clay-primary p-3 rounded-clay-sm">Primary: #8B7CF6</div>
<div className="bg-clay-primary-dark p-3 rounded-clay-sm">Primary Dark: #6D5BD0</div>
<div className="bg-clay-income p-3 rounded-clay-sm text-clay-text">Income: #34D399</div>
<div className="bg-clay-expense p-3 rounded-clay-sm">Expense: #FB7185</div>
<div className="bg-clay-warning p-3 rounded-clay-sm text-clay-text">Warning: #FBBF24</div>
<div className="bg-clay-info p-3 rounded-clay-sm">Info: #60A5FA</div>
<p className="clay-caption mb-3">Dùng nút chuyển ở góc trên bên phải để kiểm tra cùng một semantic token trong cả hai giao diện.</p>
<div className="grid grid-cols-2 gap-3 text-xs font-semibold">
<div className="bg-clay-bg text-clay-text p-3 rounded-clay-sm border border-clay-border">Nền ứng dụng</div>
<div className="bg-clay-surface text-clay-text p-3 rounded-clay-sm border border-clay-highlight/60 shadow-clay-pressed">Card / Input</div>
<div className="bg-clay-primary text-clay-on-primary p-3 rounded-clay-sm">Primary</div>
<div className="bg-clay-primary-dark text-clay-on-primary p-3 rounded-clay-sm">Primary strong</div>
<div className="bg-clay-income text-clay-on-status p-3 rounded-clay-sm">Income / Success</div>
<div className="bg-clay-expense text-clay-on-status p-3 rounded-clay-sm">Expense / Error</div>
<div className="bg-clay-warning text-clay-on-status p-3 rounded-clay-sm">Warning</div>
<div className="bg-clay-info text-clay-on-status p-3 rounded-clay-sm">Info</div>
</div>
</div>
</Card>
......@@ -150,7 +151,7 @@ const StyleGuidePage: React.FC = () => {
<Card>
<h2 className="clay-title-h2 mb-4 border-b pb-2 text-clay-primary-dark">3. Cards (Thẻ)</h2>
<div className="flex flex-col gap-4">
<Card className="bg-clay-surface p-4 border border-white/50">
<Card className="bg-clay-surface p-4 border border-clay-highlight/50">
<h4 className="font-baloo font-bold text-clay-text">Raised Card (Mặc định)</h4>
<p className="text-sm text-clay-text-muted">Thẻ nổi trên bề mặt nền mịn màng.</p>
</Card>
......
import React, { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
......@@ -11,6 +11,45 @@ import { WalletArtwork } from "@/components/shared/WalletArtwork";
const balancePattern = /^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function formatBalanceInput(value: string): string {
if (!value || value === "-") {
return value;
}
const isNegative = value.startsWith("-");
const [integerPart, decimalPart] = value.replace("-", "").split(".");
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${isNegative ? "-" : ""}${groupedInteger}${decimalPart !== undefined ? `,${decimalPart}` : ""}`;
}
function parseBalanceInput(value: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const isNegative = trimmedValue.startsWith("-");
const unsignedValue = trimmedValue.replace(/-/g, "");
let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined;
if (unsignedValue.includes(",")) {
[integerDisplay, decimalDisplay] = unsignedValue.split(",", 2);
} else if (/^\d+\.\d{0,2}$/.test(unsignedValue)) {
// Also accept a decimal point when users paste values such as 1200.50.
const separatorIndex = unsignedValue.lastIndexOf(".");
integerDisplay = unsignedValue.slice(0, separatorIndex);
decimalDisplay = unsignedValue.slice(separatorIndex + 1);
}
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 `${isNegative ? "-" : ""}${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
}
const walletFormSchema = z.object({
name: z.string().trim().min(1, "Vui lòng nhập tên ví").max(100, "Tên ví tối đa 100 ký tự"),
balance: z.string().trim().regex(balancePattern, "Số dư có tối đa 16 chữ số và 2 số thập phân"),
......@@ -52,6 +91,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
}) => {
const formId = wallet ? `edit-wallet-${wallet.id}` : "create-wallet";
const {
control,
register,
handleSubmit,
reset,
......@@ -112,7 +152,23 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<Input label="Tên ví *" placeholder="Ví dụ: Tiền mặt" error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<div className="grid grid-cols-[1fr_96px] gap-3">
<Input label="Số dư *" inputMode="decimal" placeholder="0" error={errors.balance?.message} disabled={isSubmitting} {...register("balance")} />
<Controller
name="balance"
control={control}
render={({ field }) => (
<Input
{...field}
label="Số dư *"
inputMode="decimal"
placeholder="0"
error={errors.balance?.message}
disabled={isSubmitting}
className="text-right tabular-nums"
value={formatBalanceInput(field.value)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value))}
/>
)}
/>
<Input label="Tiền tệ *" maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
</div>
......@@ -144,7 +200,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
type="button"
aria-label={`Chọn màu ${color}`}
aria-pressed={selectedColor === color}
className={`h-9 w-9 rounded-full border-2 transition-all duration-200 ease-in-out ${selectedColor === color ? "scale-110 border-clay-text shadow-clay-raised" : "border-white/70"}`}
className={`h-9 w-9 rounded-full border-2 transition-all duration-200 ease-in-out ${selectedColor === color ? "scale-110 border-clay-text shadow-clay-raised" : "border-clay-highlight/70"}`}
style={{ backgroundColor: color }}
onClick={() => setValue("color", color, { shouldValidate: true })}
/>
......
import React from "react";
export const WalletSkeleton: React.FC = () => (
<div className="animate-pulse rounded-clay-lg border border-white/40 bg-clay-surface p-4 shadow-clay-raised">
<div className="animate-pulse rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-4 shadow-clay-raised">
<div className="flex items-center gap-3">
<div className="h-14 w-14 rounded-clay bg-clay-primary/15" />
<div className="flex-1 space-y-2">
......
......@@ -118,28 +118,28 @@ const WalletsPage: React.FC = () => {
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
<Card className="relative overflow-hidden bg-clay-primary p-5 text-white">
<div className="absolute -right-6 -top-7 h-28 w-28 rounded-full bg-white/10" />
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary">
<div className="absolute -right-6 -top-7 h-28 w-28 rounded-full bg-clay-on-primary/10" />
<div className="absolute -bottom-9 right-16 h-24 w-24 rounded-full bg-clay-primary-dark/25" />
<div className="relative flex items-start justify-between gap-3">
<div>
<p className="font-nunito text-sm font-bold text-white/80">Tổng số dư theo tiền tệ</p>
<p className="font-nunito text-sm font-bold text-clay-on-primary">Tổng số dư theo tiền tệ</p>
{summary.isLoading ? (
<div className="mt-3 h-8 w-40 animate-pulse rounded-full bg-white/20" />
<div className="mt-3 h-8 w-40 animate-pulse rounded-full bg-clay-on-primary/20" />
) : balances.length > 0 ? (
<div className="mt-2 flex flex-col gap-1">
{balances.map((item) => (
<p key={item.currency} className="font-baloo text-2xl font-bold">
<p key={item.currency} className="font-baloo text-2xl font-bold text-clay-on-primary">
{formatWalletBalance(item.balance, item.currency)}
</p>
))}
</div>
) : (
<p className="mt-2 font-baloo text-2xl font-bold">Chưa có số dư</p>
<p className="mt-2 font-baloo text-2xl font-bold text-clay-on-primary">Chưa có số dư</p>
)}
<p className="mt-2 font-nunito text-xs font-semibold text-white/75">{activeWalletCount} ví đang hoạt động</p>
<p className="mt-2 font-nunito text-xs font-semibold text-clay-on-primary">{activeWalletCount} ví đang hoạt động</p>
</div>
<div className="rounded-clay bg-white/20 p-3 shadow-clay-pressed"><WalletIcon size={30} /></div>
<div className="rounded-clay bg-clay-on-primary/20 p-3 shadow-clay-pressed"><WalletIcon size={30} /></div>
</div>
</Card>
......
import { create } from "zustand";
export type ThemeMode = "light" | "dark";
const THEME_STORAGE_KEY = "finwise.theme";
function isThemeMode(value: string | null): value is ThemeMode {
return value === "light" || value === "dark";
}
function getInitialTheme(): ThemeMode {
if (typeof document !== "undefined") {
const documentTheme = document.documentElement.dataset.theme;
if (documentTheme === "light" || documentTheme === "dark") {
return documentTheme;
}
}
try {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (isThemeMode(storedTheme)) {
return storedTheme;
}
} catch {
// Storage may be unavailable in restricted webviews.
}
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
export function applyTheme(theme: ThemeMode): void {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = theme;
document.documentElement.setAttribute("zaui-theme", theme);
document.documentElement.style.colorScheme = theme;
const themeColor = theme === "dark" ? "#181726" : "#F3F0FA";
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute("content", themeColor);
}
interface ThemeState {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
}
const initialTheme = getInitialTheme();
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: initialTheme,
setTheme: (theme) => {
applyTheme(theme);
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Keep the selected theme for this session even when persistence is blocked.
}
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === "light" ? "dark" : "light"),
}));
const colorToken = (name) => `rgb(var(--color-clay-${name}) / <alpha-value>)`;
module.exports = {
darkMode: ["selector", '[zaui-theme="dark"]'],
purge: {
enabled: true,
content: ["./src/**/*.{js,jsx,ts,tsx,vue}", "./index.html"],
},
content: ["./src/**/*.{js,jsx,ts,tsx,vue}", "./index.html"],
theme: {
extend: {
colors: {
clay: {
bg: "#F3F0FA",
surface: "#EDE9F7",
primary: "#8B7CF6",
"primary-dark": "#6D5BD0",
income: "#34D399",
expense: "#FB7185",
warning: "#FBBF24",
info: "#60A5FA",
text: "#2D2A45",
"text-muted": "#8B85A6",
"shadow-light": "rgba(255, 255, 255, 0.85)",
"shadow-dark": "rgba(163, 150, 208, 0.45)",
bg: colorToken("bg"),
surface: colorToken("surface"),
primary: colorToken("primary"),
"primary-soft": colorToken("primary-soft"),
"primary-dark": colorToken("primary-dark"),
income: colorToken("income"),
"income-soft": colorToken("income-soft"),
expense: colorToken("expense"),
"expense-soft": colorToken("expense-soft"),
warning: colorToken("warning"),
"warning-soft": colorToken("warning-soft"),
info: colorToken("info"),
"info-soft": colorToken("info-soft"),
text: colorToken("text"),
"text-muted": colorToken("text-muted"),
border: colorToken("border"),
highlight: colorToken("highlight"),
"on-primary": colorToken("on-primary"),
"on-status": colorToken("on-status"),
overlay: colorToken("overlay"),
"badge-primary-bg": colorToken("badge-primary-bg"),
"badge-primary-text": colorToken("badge-primary-text"),
"badge-primary-border": colorToken("badge-primary-border"),
"badge-income-bg": colorToken("badge-income-bg"),
"badge-income-text": colorToken("badge-income-text"),
"badge-income-border": colorToken("badge-income-border"),
"badge-expense-bg": colorToken("badge-expense-bg"),
"badge-expense-text": colorToken("badge-expense-text"),
"badge-expense-border": colorToken("badge-expense-border"),
"badge-warning-bg": colorToken("badge-warning-bg"),
"badge-warning-text": colorToken("badge-warning-text"),
"badge-warning-border": colorToken("badge-warning-border"),
"badge-info-bg": colorToken("badge-info-bg"),
"badge-info-text": colorToken("badge-info-text"),
"badge-info-border": colorToken("badge-info-border"),
},
},
borderRadius: {
......@@ -28,9 +50,11 @@ module.exports = {
"clay-lg": "32px",
},
boxShadow: {
"clay-raised": "8px 8px 16px rgba(163, 150, 208, 0.45), -6px -6px 14px rgba(255, 255, 255, 0.85)",
"clay-hover": "10px 10px 20px rgba(163, 150, 208, 0.45), -8px -8px 16px rgba(255, 255, 255, 0.85)",
"clay-pressed": "inset 4px 4px 8px rgba(163, 150, 208, 0.45), inset -4px -4px 8px rgba(255, 255, 255, 0.85)",
"clay-raised": "var(--shadow-clay-raised)",
"clay-hover": "var(--shadow-clay-hover)",
"clay-pressed": "var(--shadow-clay-pressed)",
"clay-modal": "var(--shadow-clay-modal)",
"clay-progress": "var(--shadow-clay-progress)",
},
fontFamily: {
baloo: ["'Baloo 2'", "sans-serif"],
......
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