Commit 1e02bf63 authored by ThinhNC's avatar ThinhNC

fix(ui): align sliders, avatar interactions, and dark theme

parent a27b33f3
......@@ -10,9 +10,10 @@ 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.
- **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 `data-theme` lên `<html>` và đồng bộ `zaui-theme` lên cả `<html>` lẫn `<body>` vì stylesheet của ZaUI dùng selector `body[zaui-theme]`; đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
- **Đa ngôn ngữ frontend**: UI hỗ trợ `vi``en` qua `src/i18n/`, lưu lựa chọn bằng khóa `finwise.locale` và dùng `Intl` với `vi-VN`/`en-US` cho tiền tệ, số và ngày. Chỉ dịch text hiển thị; enum, mã tiền tệ, ID và payload API giữ nguyên. Khi thêm locale mới, thêm resource/config locale và translation key tương ứng, không đưa text hiển thị trực tiếp vào component.
- **Nhập liệu số tiền**: Mọi ô nhập số tiền trong ứng dụng (gồm số dư ví, số tiền giao dịch trong form, và số tiền tối thiểu/tối đa trong bộ lọc giao dịch) đều được định dạng số tự động (ngăn cách hàng nghìn cục bộ và tối đa 2 chữ số thập phân), đảm bảo trải nghiệm nhập liệu tài chính thống nhất và đồng bộ.
- **Điều khiển tiến độ tương tác**: Các thanh tiến độ cho phép người dùng điều chỉnh phải dùng cùng cấu trúc `−`/slider/`+` từ base component, hiển thị giá trị hiện tại rõ ràng và giữ label liên kết với slider. Hai nút dùng bề mặt, bóng đổ và transition Claymorphism; nút giảm/tăng phải disabled khi giá trị chạm giới hạn tương ứng. Quy tắc này không áp dụng cho `ProgressBar` chỉ dùng để hiển thị trạng thái.
- **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`.
- **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
......@@ -25,6 +26,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
dụng cho avatar JPEG/PNG/WebP tối đa 5 MB; chỉ cập nhật profile sau khi PUT thành công.
- **Crop avatar**: Trang hồ sơ mở trình chỉnh avatar khi chạm trực tiếp vào ảnh; điểm lấy nét X/Y
được lưu trên profile và dùng nhất quán ở mọi nơi hiển thị avatar.
Badge cây viết là lớp phủ không nhận pointer nằm ngoài button; vùng focus/active của button phải
cố định đúng hình tròn avatar, không được nới rộng theo badge.
Hai điều khiển Ngang/Dọc dùng cùng spacing và cùng cấu trúc nút
`−`/slider/`+`. Nút giảm hoặc tăng phải disabled khi giá trị đã chạm giới hạn tương ứng;
toàn bộ điều khiển disabled khi chưa có ảnh hoặc đang lưu. Dựa trên kích thước thật của ảnh
......
import React from "react";
export interface SliderProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"max" | "min" | "onChange" | "step" | "type" | "value"
> {
value: number;
min?: number;
max?: number;
step?: number;
buttonStep?: number;
decreaseLabel: string;
increaseLabel: string;
onValueChange: (value: number) => void;
}
type SliderStyle = React.CSSProperties & {
"--clay-slider-progress": string;
};
export const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
(
{
className = "",
buttonStep,
decreaseLabel,
disabled,
increaseLabel,
max = 100,
min = 0,
onValueChange,
step = 1,
style,
value,
...props
},
ref,
) => {
const boundedValue = Math.max(min, Math.min(max, value));
const controlStep = buttonStep ?? step;
const range = max - min;
const progress = range > 0 ? ((boundedValue - min) / range) * 100 : 0;
const sliderStyle: SliderStyle = {
...style,
"--clay-slider-progress": `${progress}%`,
};
return (
<div className="flex w-full items-center gap-3">
<button
type="button"
aria-label={decreaseLabel}
disabled={disabled || boundedValue <= min}
onClick={() => onValueChange(Math.max(min, boundedValue - controlStep))}
className="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-xl font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:scale-95 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
</button>
<input
{...props}
ref={ref}
type="range"
min={min}
max={max}
step={step}
value={boundedValue}
disabled={disabled}
style={sliderStyle}
onChange={(event) => onValueChange(Number(event.target.value))}
className={`clay-slider min-w-0 flex-1 ${className}`}
/>
<button
type="button"
aria-label={increaseLabel}
disabled={disabled || boundedValue >= max}
onClick={() => onValueChange(Math.min(max, boundedValue + controlStep))}
className="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-xl font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:scale-95 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
+
</button>
</div>
);
},
);
Slider.displayName = "Slider";
......@@ -170,6 +170,69 @@ body {
color: rgb(var(--color-clay-text-muted));
}
/* Native range control aligned with the shared Claymorphism tokens. */
.clay-slider {
height: 16px;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
border: 1px solid rgb(var(--color-clay-text) / 0.05);
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-clay-primary)) 0 var(--clay-slider-progress),
rgb(var(--color-clay-bg)) var(--clay-slider-progress) 100%
);
box-shadow: var(--shadow-clay-pressed);
transition: all 200ms ease-in-out;
}
.clay-slider:focus {
outline: none;
}
.clay-slider:focus-visible {
outline: 2px solid rgb(var(--color-clay-primary) / 0.55);
outline-offset: 4px;
}
.clay-slider:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.clay-slider::-webkit-slider-thumb {
width: 24px;
height: 24px;
appearance: none;
-webkit-appearance: none;
border: 3px solid rgb(var(--color-clay-highlight));
border-radius: 9999px;
background: rgb(var(--color-clay-primary));
box-shadow: var(--shadow-clay-raised);
transition: all 200ms ease-in-out;
}
.clay-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border: 3px solid rgb(var(--color-clay-highlight));
border-radius: 9999px;
background: rgb(var(--color-clay-primary));
box-shadow: var(--shadow-clay-raised);
transition: all 200ms ease-in-out;
}
.clay-slider:active::-webkit-slider-thumb {
box-shadow: var(--shadow-clay-pressed);
transform: scale(0.94);
}
.clay-slider:active::-moz-range-thumb {
box-shadow: var(--shadow-clay-pressed);
transform: scale(0.94);
}
/* Custom scrollbars inside Claymorphism components */
::-webkit-scrollbar {
width: 6px;
......@@ -197,8 +260,10 @@ body {
}
.zaui-header .zaui-header-title,
.zaui-header .zaui-header-back-icon {
.zaui-header .zaui-header-back-btn,
.zaui-header .zaui-header-back-btn .zaui-icon {
color: rgb(var(--color-clay-text));
transition: color 200ms ease-in-out;
}
/* Keep app controls clear of Zalo's native `right-buttons` area. */
......
......@@ -491,6 +491,8 @@
"progress": "8. Progress Bar",
"progressHint": "Use for budgets and saving goals. Adjust the slider to preview updates.",
"adjustProgress": "Adjust progress:",
"decreaseProgress": "Decrease progress",
"increaseProgress": "Increase progress",
"defaultBudget": "Primary (Default budget):",
"saving": "Income (Savings):",
"spent": "Expense (Spent):",
......
......@@ -500,6 +500,8 @@
"progress": "8. Thanh tiến độ",
"progressHint": "Dùng cho quản lý ngân sách & mục tiêu tiết kiệm. Điều chỉnh thanh trượt để xem cập nhật trực quan.",
"adjustProgress": "Chỉnh tiến độ:",
"decreaseProgress": "Giảm tiến độ",
"increaseProgress": "Tăng tiến độ",
"defaultBudget": "Chính (Ngân sách mặc định):",
"saving": "Thu nhập (Tiết kiệm):",
"spent": "Chi tiêu (Đã chi):",
......
......@@ -2,6 +2,7 @@ import React, { PointerEvent, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Slider } from "@/components/ui/Slider";
import { useI18n } from "@/i18n";
export interface AvatarPosition {
......@@ -68,9 +69,10 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
return (
<div
className={`grid w-full grid-cols-[4.5rem_1fr_2.5rem] items-center gap-2 transition-all duration-200 ease-in-out ${locked ? "opacity-55" : ""}`}
className={`flex w-full flex-col gap-2 transition-all duration-200 ease-in-out ${locked ? "opacity-55" : ""}`}
title={locked ? lockedMessage : undefined}
>
<div className="flex items-center justify-between gap-3 px-1">
<label htmlFor={id} className="flex items-center gap-1 font-nunito text-sm font-bold text-clay-text">
{label}
{locked && (
......@@ -89,41 +91,26 @@ const AvatarAdjustmentControl: React.FC<AvatarAdjustmentControlProps> = ({
</svg>
)}
</label>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={decreaseLabel}
disabled={disabled || value <= min}
onClick={() => onChange(Math.max(min, value - buttonStep))}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-lg font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
<output
htmlFor={id}
className="w-12 text-right font-baloo text-base font-bold tabular-nums text-clay-primary"
>
</button>
<input
{displayValue}
</output>
</div>
<Slider
id={id}
aria-label={label}
type="range"
min={min}
max={max}
step={sliderStep}
buttonStep={buttonStep}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
className="min-w-0 flex-1 cursor-pointer accent-clay-primary disabled:cursor-not-allowed disabled:opacity-50"
decreaseLabel={decreaseLabel}
increaseLabel={increaseLabel}
onValueChange={onChange}
aria-valuetext={displayValue}
/>
<button
type="button"
aria-label={increaseLabel}
disabled={disabled || value >= max}
onClick={() => onChange(Math.min(max, value + buttonStep))}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-lg font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
+
</button>
</div>
<span className="text-right font-nunito text-xs font-bold text-clay-text-muted">
{displayValue}
</span>
</div>
);
};
......
......@@ -344,26 +344,28 @@ 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-clay-highlight/50">
<div className="relative h-20 w-20 flex-none">
<button
type="button"
aria-label={t("profile.editAvatar")}
onClick={openAvatarEditor}
className="group relative rounded-full transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
className="flex h-20 w-20 items-center justify-center rounded-full p-0 leading-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
>
<Avatar
size="lg"
src={currentAvatarUrl || ""}
positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover"
className="pointer-events-none 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-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out group-active:shadow-clay-pressed">
</button>
<span className="pointer-events-none 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">
<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" />
</svg>
</span>
</button>
</div>
<div className="flex-1 min-w-0">
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || t("profile.notSet")}</h2>
<p className="clay-caption truncate">{user?.email}</p>
......
......@@ -10,6 +10,7 @@ import { Avatar } from "@/components/ui/Avatar";
import { Modal } from "@/components/ui/Modal";
import { Tabs } from "@/components/ui/Tabs";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { Slider } from "@/components/ui/Slider";
import {
IconGradients,
HomeIcon,
......@@ -27,9 +28,55 @@ import {
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
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 navigate = useNavigate();
const { t } = useI18n();
const { t, intlLocale } = useI18n();
// State for interactive tab component
const [activeTab, setActiveTab] = useState("tab-1");
const tabItems = [
......@@ -44,6 +91,10 @@ const StyleGuidePage: React.FC = () => {
// State for interactive progress bar values
const [progressVal, setProgressVal] = useState(65);
// Normalized amount values; formatting follows the active locale.
const [amountValue, setAmountValue] = useState("");
const [modalAmountValue, setModalAmountValue] = useState("");
// Options for select component
const selectOptions = [
{ value: "vnd", label: t("styleGuide.currencies.vnd") },
......@@ -176,7 +227,10 @@ const StyleGuidePage: React.FC = () => {
<Input
label={t("styleGuide.amount")}
placeholder={t("styleGuide.amountPlaceholder")}
type="number"
inputMode="decimal"
className="text-right font-semibold tabular-nums"
value={formatAmountInput(amountValue, intlLocale)}
onChange={(event) => setAmountValue(parseAmountInput(event.target.value, intlLocale))}
/>
{/* TextInput with error */}
......@@ -282,17 +336,29 @@ const StyleGuidePage: React.FC = () => {
<div className="flex flex-col gap-4">
{/* Interactive Control */}
<div className="flex items-center gap-3 mb-2">
<span className="text-xs font-semibold text-clay-text">{t("styleGuide.adjustProgress")}</span>
<input
type="range"
min="0"
max="100"
<div className="mb-2 flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 px-1">
<label htmlFor="style-guide-progress" className="text-xs font-semibold text-clay-text">
{t("styleGuide.adjustProgress")}
</label>
<output
htmlFor="style-guide-progress"
className="w-12 text-right font-baloo text-base font-bold tabular-nums text-clay-primary"
>
{progressVal}%
</output>
</div>
<Slider
id="style-guide-progress"
min={0}
max={100}
step={5}
value={progressVal}
onChange={(e) => setProgressVal(Number(e.target.value))}
className="w-full accent-clay-primary"
decreaseLabel={t("styleGuide.decreaseProgress")}
increaseLabel={t("styleGuide.increaseProgress")}
onValueChange={setProgressVal}
aria-valuetext={`${progressVal}%`}
/>
<span className="text-sm font-bold text-clay-primary w-10 text-right">{progressVal}%</span>
</div>
{/* Variations */}
......@@ -342,7 +408,14 @@ const StyleGuidePage: React.FC = () => {
{t("styleGuide.modalHint")}
</p>
<Input label={t("styleGuide.transactionName")} placeholder={t("styleGuide.namePlaceholder")} />
<Input label={t("styleGuide.amount")} type="number" placeholder="0" />
<Input
label={t("styleGuide.amount")}
inputMode="decimal"
placeholder={t("styleGuide.amountPlaceholder")}
className="text-right font-semibold tabular-nums"
value={formatAmountInput(modalAmountValue, intlLocale)}
onChange={(event) => setModalAmountValue(parseAmountInput(event.target.value, intlLocale))}
/>
</div>
</Modal>
</Card>
......
......@@ -37,6 +37,7 @@ export function applyTheme(theme: ThemeMode): void {
document.documentElement.dataset.theme = theme;
document.documentElement.setAttribute("zaui-theme", theme);
document.body?.setAttribute("zaui-theme", theme);
document.documentElement.style.colorScheme = theme;
const themeColor = theme === "dark" ? "#181726" : "#F3F0FA";
......
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