You need to sign in or sign up before continuing.
Commit 8621dab9 authored by ThinhNC's avatar ThinhNC

feat: setup Claymorphism design system & rules framework

parents
# Checklist thêm trang mới
- [ ] Tạo thư mục trang tương ứng dưới `src/pages/<tên-trang>/`.
- [ ] Khai báo Component trang chính (ví dụ: `index.tsx`).
- [ ] Đăng ký tuyến đường (route) mới trong layout gốc hoặc hệ thống router chính của Zalo Mini App.
- [ ] Thiết kế giao diện sử dụng 100% components có sẵn từ `src/components/ui/` (Button, Card, Input, v.v.).
- [ ] Định nghĩa các Zod schema nếu trang có chứa form thu thập dữ liệu và tích hợp với React Hook Form.
- [ ] Tạo services và endpoints tương tác tương ứng trong `src/services/` nếu trang cần kết nối backend.
- [ ] Viết custom React Query hook để thực hiện query/mutation dữ liệu.
- [ ] Xử lý các trạng thái: Loading (sử dụng Skeleton bo tròn), Error (nổi bật đất sét), và Empty (thông báo thân thiện).
- [ ] Đảm bảo thiết kế responsive đầy đủ cho mọi kích thước màn hình điện thoại di động.
- [ ] Chạy build kiểm tra lỗi TypeScript: `npm run build`.
# Checklist review thay đổi
## UI/UX & Claymorphism
- [ ] Mọi nút bấm (Button) đều có hiệu ứng thụt xuống (pressed shadow & translate-y) khi click.
- [ ] Các card và modal đều sử dụng bo góc lớn (`rounded-clay-lg` hoặc `rounded-clay`) và shadow kép mềm mại.
- [ ] Hộp nhập liệu (Input) ở trạng thái thụt lõm xuống mặc định để tạo cảm giác thực tế.
- [ ] Sử dụng đúng hệ phông chữ: Baloo 2 cho Headings/Số tiền và Nunito cho Body.
- [ ] Màu sắc hiển thị đúng bảng màu Pastel dịu mắt của Claymorphism, không dùng màu gốc thô (red-500, blue-600, v.v.).
- [ ] Các icon hiển thị là các nét vẽ mập mạp, nhiều màu sắc hoặc icon 3D blob đã quy chuẩn.
## Logic & TypeScript
- [ ] Không sử dụng kiểu `any` trong code mới viết.
- [ ] Kiểm tra lỗi biên dịch TypeScript không gặp bất kỳ cảnh báo nào.
- [ ] Form submit được ngăn chặn hành vi mặc định và được validate qua schema của Zod.
- [ ] Trạng thái gọi API có đầy đủ bộ đệm và tự động làm mới (React Query).
## Tương thích & Native Zalo
- [ ] Sử dụng đúng các component cấu trúc của ZMP UI (`Page`, `Header`).
- [ ] Code gọi các ZMP native API được bao bọc an toàn để tránh crash khi chạy thử trên Web browser thường.
# Project Memory (Frontend)
File này lưu trữ các quyết định thiết kế dài hạn và trạng thái hiện tại của dự án để đảm bảo tính nhất quán qua các phiên làm việc của Agent.
## Quyết định đang có hiệu lực
- **Design System Claymorphism**: Sử dụng phong cách đất sét nặn 3D vui tươi, mềm mại:
- Bóng đổ kép (dual-tone soft shadow) được định nghĩa qua các token: `shadow-clay-raised`, `shadow-clay-hover`, `shadow-clay-pressed`.
- Sử dụng hai phông chữ: `Baloo 2` (Heading & Số tiền) và `Nunito` (Nội dung chính).
- 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.
- **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`.
- **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).
## Trạng thái đã biết
- Đã sao chép khung boilerplate từ Zalo Mini App blank template (React + Vite + TypeScript).
- Đã cài đặt và cấu hình `zmp-sdk``zmp-ui` làm thư viện nền tảng cơ bản.
- Đang bắt đầu quá trình triển khai cấu hình Tailwind, import Fonts, viết các base components phong cách Claymorphism, và dựng trang Style Guide để kiểm thử trực quan.
## Khi cập nhật file này
- Ghi lại các quyết định lâu dài mới về UI/UX hoặc cấu trúc frontend.
- Cập nhật danh sách thư viện hoặc thay đổi lớn trong phương thức kết nối API.
# Bản đồ dự án FinWise Frontend
## Mục đích
Ứng dụng quản lý tài chính cá nhân dành cho người dùng Zalo ("Sổ tay Chi tiêu & Báo cáo Tài chính"). Giúp theo dõi thu nhập/chi tiêu, quản lý ví, lập ngân sách, tiết kiệm và hỗ trợ bằng AI Assistant.
## Công nghệ
- **Framework**: ZMP CLI (Zalo Mini Program) - React 18 + Vite 5 + TypeScript
- **UI Kit**: `zmp-ui` cho các thành phần bắt buộc (Page, Sheet container, Navigation) + Claymorphism Base Components làm nền hiển thị chính.
- **Native SDK**: `zmp-sdk` để lấy thông tin Zalo user, đăng nhập, chụp ảnh hóa đơn OCR.
- **Styling**: Tailwind CSS v3 với cấu hình tokens Claymorphism.
- **Data Fetching / Cache**: TanStack Query (React Query) v5.
- **State Management**: Zustand cho state toàn cục (auth, ui settings).
- **Form Validation**: Zod + React Hook Form.
## Cấu trúc thư mục (Feature-based)
```text
src/
├── app.ts # Điểm vào chính của ứng dụng
├── components/
│ ├── ui/ # Base components Claymorphism (Button, Card, Input, v.v.)
│ └── shared/ # Components nghiệp vụ dùng chung (TransactionItem, WalletCard)
├── pages/ # Thư mục chứa các trang nghiệp vụ
│ ├── home/ # Trang chủ
│ ├── wallet/ # Quản lý ví
│ ├── transaction/ # Giao dịch
│ ├── budget/ # Ngân sách
│ ├── saving-goal/ # Mục tiêu tiết kiệm
│ ├── report/ # Báo cáo tài chính
│ ├── ai-assistant/ # Trợ lý AI tài chính
│ └── style-guide/ # Trang thử nghiệm & tài liệu Design System
├── css/
│ ├── tailwind.scss # Import directives của Tailwind
│ └── app.scss # CSS tùy chỉnh và các lớp phong cách bổ sung
├── hooks/ # Custom hooks dùng chung
├── services/ # Module gọi API (auth.service.ts, wallet.service.ts)
├── lib/
│ ├── api-client.ts # Axios instance có đính kèm Bearer Token & Interceptor tự refresh
│ └── query-client.ts # Cấu hình TanStack Query Client
├── stores/ # Zustand stores (ví dụ: auth-store.ts)
├── types/ # Kiểu dữ liệu TypeScript khớp với BE
└── utils/ # Hàm tiện ích (định dạng tiền tệ, ngày tháng)
```
## Luồng API & Dữ liệu
```text
Component / Page
-> React Hook Form / Zod (Validation phía Client)
-> TanStack Query (Hooks sử dụng Services)
-> services/ (Gọi API qua api-client)
-> Backend API (/api/v1)
```
- **Base URL (Development)**: `http://localhost:7777/api/v1` (Port 7777 khớp với Backend `.env`).
- **Response Format**:
```json
{
"success": boolean,
"message": string,
"data": <dữ liệu trả về>,
"errors": [] | null
}
```
# Quy tắc kiến trúc (Frontend)
## 1. Phân chia thư mục & Thành phần
### Base UI Components (`src/components/ui/`)
- Chỉ chứa các thành phần nguyên tử, tái sử dụng cao (Button, Card, Input, Badge, ProgressBar, v.v.).
- Không chứa logic nghiệp vụ, không kết nối API, không phụ thuộc Zustand store.
- Nhận dữ liệu và sự kiện hoàn toàn thông qua `props`.
- Bắt buộc áp dụng đúng các token Claymorphism.
### Shared Components (`src/components/shared/`)
- Chứa các component nghiệp vụ được tái sử dụng ở nhiều trang (ví dụ: `TransactionItem`, `WalletCard`, `CategoryPicker`).
- Có thể chứa logic nghiệp vụ phụ trợ hoặc gọi hooks, nhưng giữ tính độc lập cao.
### Pages (`src/pages/`)
- Mỗi chức năng nghiệp vụ nằm trong một thư mục con riêng biệt (ví dụ: `home`, `wallet`, `report`).
- Chứa màn hình hiển thị chính (`page.tsx` hoặc `index.tsx`) và các component phụ chỉ phục vụ riêng cho trang đó.
- Là nơi điều phối luồng dữ liệu, kích hoạt loading skeletons và xử lý errors.
## 2. Quản lý State & Dữ liệu
### Server State (TanStack Query)
- Mọi dữ liệu lấy từ backend (Wallets, Transactions, Reports, v.v.) phải đi qua TanStack Query.
- Viết custom hooks đặt tại `src/hooks/` hoặc trực tiếp trong thư mục module nếu chỉ dùng ở một nơi.
- Cấu hình cache time và stale time hợp lý, không gọi API vô tội vạ.
### Client State (Zustand)
- Dùng Zustand cho các trạng thái toàn cục của ứng dụng phía client (thông tin đăng nhập/token, cấu hình giao diện ẩn/hiện, cài đặt chung).
- Đặt tại thư mục `src/stores/`.
- Không lạm dụng Zustand cho các dữ liệu có thể quản lý trực tiếp bằng React state hoặc query cache.
### Form State & Validation (Zod + React Hook Form)
- Biện kiểm soát dữ liệu đầu vào: Tất cả các form thêm/sửa giao dịch, thiết lập ví, đặt ngân sách phải được validate bằng Zod schema trước khi submit.
- Định nghĩa schema rõ ràng trong file component hoặc `<feature>.validation.ts`.
# Quy ước kỹ thuật mặc định (Frontend)
## 1. TypeScript & Code Style
- Bắt buộc khai báo kiểu đầy đủ cho props, state, và dữ liệu API. Không dùng `any`.
- Các file React Component dùng đuôi `.tsx`.
- Viết component dạng Functional Component (`const Component: React.FC<Props> = ...` hoặc `function Component(props: Props)...`).
- Định nghĩa rõ ràng kiểu dữ liệu của API response khớp với Backend DTO (đặt trong `src/types/`).
## 2. Quy tắc styling với Tailwind CSS
- Sử dụng các lớp tiện ích được cấu hình sẵn theo Claymorphism:
- Màu nền: `bg-clay-bg`, `bg-clay-surface`.
- Màu nhấn: `bg-clay-primary` (tím periwinkle), `bg-clay-primary-dark`.
- Màu trạng thái: `bg-clay-income` (xanh mint), `bg-clay-expense` (hồng coral), `bg-clay-warning` (vàng nắng), `bg-clay-info` (xanh dương nhạt).
- Màu văn bản: `text-clay-text` (tím than đậm), `text-clay-text-muted` (tím xám nhạt).
- Bo góc: `rounded-clay-sm` (16px), `rounded-clay` (24px), `rounded-clay-lg` (32px), `rounded-full` (cho pill).
- Bóng đổ: `shadow-clay-raised`, `shadow-clay-hover`, `shadow-clay-pressed`.
- Mọi hiệu ứng chuyển trạng thái (hover, active, focus) bắt buộc phải cấu hình `transition-all duration-200 ease-in-out` để đảm bảo độ mượt mà của đất sét nặn.
## 3. Tương tác với Backend API
- Mọi request sử dụng instance `apiClient` từ `src/lib/api-client.ts`.
- Gắn token tự động qua `Authorization: Bearer <access_token>` header.
- Xử lý refresh token khi gặp lỗi 401 tự động trong interceptor của Axios.
- Luôn kiểm tra thuộc tính `success` của response trước khi xử lý tiếp dữ liệu.
## 4. Tương tác với Zalo SDK (`zmp-sdk`)
- Sử dụng các API an toàn từ `zmp-sdk` (ví dụ: `getUserInfo`, `login`, `openCamera`).
- Luôn bọc các hàm gọi SDK trong khối `try...catch` để đề phòng trường hợp chạy trên trình duyệt thường ngoài ứng dụng Zalo.
# Quy trình làm việc (Frontend)
## 1. Trước khi sửa đổi
- Kiểm tra trạng thái git: Chạy `git status` để xem các tập tin đang thay đổi. Tránh ghi đè hoặc commit nhầm code không liên quan của người dùng.
- Tìm hiểu cấu trúc trang hoặc thành phần chuẩn bị chỉnh sửa.
## 2. Khi triển khai
- Tập trung vào tính năng được yêu cầu; không refactor lan man.
- Sử dụng các UI component có sẵn trong `src/components/ui/`, không tự tạo phong cách thiết kế khác.
- Khi thêm một trang mới, phải cập nhật router/navigation trong layout gốc để người dùng có thể điều hướng được.
## 3. Xác minh & Kiểm thử (Verification)
- Luôn chạy build để kiểm tra lỗi biên dịch TypeScript và đóng gói tài nguyên:
```bash
npm run build
```
- Khởi động môi trường dev cục bộ để kiểm tra giao diện trực quan:
```bash
zmp start
```
- Kiểm tra độ tương thích responsive trên các kích thước màn hình thiết bị di động (tối thiểu là tỷ lệ màn hình 375x812 tiêu chuẩn).
## 4. Trước khi bàn giao
- Xem lại git diff để đảm bảo không có API key nhạy cảm, log thử nghiệm hoặc file thừa.
- Viết báo cáo ngắn gọn mô tả các thay đổi chính, cách kiểm thử thủ công và các ảnh chụp màn hình/video demo (nếu có thay đổi UI).
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Misc
.DS_Store
Thumbs.db
# Production build
www/
# FinWise Frontend Agent Guide
Tài liệu này là điểm vào chính cho mọi phiên làm việc với Codex/Agent trong frontend repository (`finwise-miniapp-fe`).
Mục tiêu là giữ cách phân tích, thiết kế giao diện Claymorphism, và triển khai tính năng nhất quán.
## Context bắt buộc
Trước khi sửa code:
1. Đọc `.agents/project.md`.
2. Đọc các quy tắc trong `.agents/rules/` có liên quan; với thay đổi code frontend, tối thiểu đọc cả ba file:
- `.agents/rules/architecture.md`
- `.agents/rules/tech-defaults.md`
- `.agents/rules/workflow.md`
3. Đọc `.agents/memory.md` để biết trạng thái và quyết định dài hạn hiện tại.
4. Nếu có `.agents/local.md`, đọc file đó sau cùng. Đây là ghi chú riêng của máy và không được commit.
Code và cấu hình đang chạy là nguồn sự thật cao nhất. Nếu tài liệu khác với code, hãy nêu sự khác biệt, làm theo yêu cầu hiện tại và cập nhật tài liệu khi thay đổi đã được xác nhận.
## Nguyên tắc cốt lõi
- **Nhất quán Design System**: TUYỆT ĐỐI tuân thủ phong cách Claymorphism đã cấu hình trong `tailwind.config.js` và các base components trong `src/components/ui/`. Không tự sáng chế các màu sắc, shadow hay border-radius mới.
- **Tách biệt Logic**: Tách rõ components hiển thị (UI) và logic gọi API (services, react-query hooks) hoặc quản lý state (Zustand).
- **Phản hồi trạng thái (Feedback states)**: Luôn xử lý các trạng thái loading, error, empty cho mọi màn hình tương tác dữ liệu từ API. Sử dụng hiệu ứng skeleton bo tròn theo phong cách đất sét.
- **TypeScript Strict**: Định nghĩa kiểu rõ ràng cho tất cả request, response và component props. Tránh sử dụng kiểu `any`.
- **An toàn bảo mật**: Không lưu các API key hay thông tin nhạy cảm vào code nguồn. Dùng biến môi trường thông qua `.env`.
## Hoàn tất công việc
- Kiểm tra diff để không ghi đè thay đổi có sẵn của người dùng.
- Chạy kiểm tra phù hợp theo `.agents/rules/workflow.md`.
- Nêu ngắn gọn: file đã đổi, kiểm tra đã chạy, và hướng dẫn verify thủ công nếu cần.
- Khi một quyết định giao diện hoặc trạng thái dự án bền vững thay đổi, cập nhật `.agents/memory.md`.
# Zalo Mini App
## Development
### Using Zalo Mini App Extension
1. Install [Visual Studio Code](https://code.visualstudio.com/download) and [Zalo Mini App Extension](https://mini.zalo.me/docs/dev-tools).
1. In the **Home** tab, process **Config App ID** and **Install Dependencies**.
1. Navigate to the **Run** tab, select the suitable launcher, and click **Start**.
### Using Zalo Mini App CLI
1. [Install Node JS](https://nodejs.org/en/download/).
1. [Install Zalo Mini App CLI](https://mini.zalo.me/docs/dev-tools/cli/intro/).
1. **Install dependencies**:
```bash
npm install
```
1. **Start** the dev server:
```bash
zmp start
```
1. **Open** `localhost:3000` in your browser.
## Deployment
1. **Create** a mini program. For instructions on how to create a mini program, please refer to the [Coffee Shop Tutorial](https://mini.zalo.me/tutorial/coffee-shop/step-1/)
1. **Deploy** your mini program to Zalo using the mini app ID created.
- **Using Zalo Mini App Extension**: navigate to the **Deploy** panel > **Login** > **Deploy**.
- **Using Zalo Mini App CLI**:
```bash
zmp login
zmp deploy
```
1. Open the mini app in Zalo by scanning the QR code.
## Resources
- [Zalo Mini App Official Website](https://mini.zalo.me/)
- [ZaUI Documentation](https://mini.zalo.me/documents/zaui/)
- [ZMP SDK Documentation](https://mini.zalo.me/documents/api/)
- [DevTools Documentation](https://mini.zalo.me/docs/dev-tools/)
- [Ready-made Mini App Templates](https://mini.zalo.me/zaui-templates)
- [Community Support](https://mini.zalo.me/community)
{
"app": {
"title": "zmp-blank-templates",
"textColor": {
"light": "black",
"dark": "white"
},
"statusBar": "transparent",
"actionBarHidden": true,
"hideIOSSafeAreaBottom": true,
"hideAndroidBottomNavigationBar": false
},
"listCSS": [],
"listSyncJS": [],
"listAsyncJS": []
}
{
"name": "zmp-blank-templates",
"private": true,
"version": "1.0.0",
"description": "zmp-blank-templates",
"repository": "",
"license": "UNLICENSED",
"browserslist": [
"Android >= 5",
"IOS >= 9.3",
"Edge >= 15",
"Safari >= 9.1",
"Chrome >= 49",
"Firefox >= 31",
"Samsung >= 5"
],
"scripts": {
"login": "zmp login",
"start": "zmp start",
"deploy": "zmp deploy",
"build": "vite build"
},
"dependencies": {
"jotai": "^2.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zmp-sdk": "latest",
"zmp-ui": "latest"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"cross-env": "^7.0.3",
"postcss": "^8.4.38",
"postcss-cli": "^8.3.1",
"postcss-preset-env": "^6.7.0",
"sass": "^1.76.0",
"tailwindcss": "^3.4.3",
"vite": "^5.2.13",
"zmp-vite-plugin": "latest"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
const tailwindcss = require("tailwindcss");
module.exports = {
plugins: [tailwindcss("./tailwind.config.js"), require("autoprefixer")],
};
// ZaUI stylesheet
import "zmp-ui/zaui.css";
// Tailwind stylesheet
import "@/css/tailwind.scss";
// Your stylesheet
import "@/css/app.scss";
// React core
import React from "react";
import { createRoot } from "react-dom/client";
// Mount the app
import Layout from "@/components/layout";
// Expose app configuration
import appConfig from "../app-config.json";
if (!window.APP_CONFIG) {
window.APP_CONFIG = appConfig as any;
}
const root = createRoot(document.getElementById("app")!);
root.render(React.createElement(Layout));
import { useEffect, useState } from "react";
import { Text } from "zmp-ui";
function Clock() {
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = now.toLocaleString("vi-VN", {
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);
}, []);
return <Text className="font-mono">{time}</Text>;
}
export default Clock;
import { getSystemInfo } from "zmp-sdk";
import {
AnimationRoutes,
App,
Route,
SnackbarProvider,
ZMPRouter,
} from "zmp-ui";
import { AppProps } from "zmp-ui/app";
import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide";
const Layout = () => {
return (
<App theme={getSystemInfo().zaloTheme as AppProps["theme"]}>
<SnackbarProvider>
<ZMPRouter>
<AnimationRoutes>
<Route path="/" element={<HomePage />}></Route>
<Route path="/style-guide" element={<StyleGuidePage />}></Route>
</AnimationRoutes>
</ZMPRouter>
</SnackbarProvider>
</App>
);
};
export default Layout;
import { SVGProps } from "react";
function Logo(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="88"
height="40"
viewBox="0 0 88 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g opacity="0.2">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.3332 24.25C11.1156 24.25 11.7498 24.8842 11.7498 25.6666C11.7498 26.4491 11.1156 27.0833 10.3332 27.0833C9.55077 27.0833 8.9165 26.4491 8.9165 25.6666C8.9165 24.8842 9.55077 24.25 10.3332 24.25ZM15.9998 24.25C16.7823 24.25 17.4165 24.8842 17.4165 25.6666C17.4165 26.4491 16.7823 27.0833 15.9998 27.0833C15.2174 27.0833 14.5832 26.4491 14.5832 25.6666C14.5832 24.8842 15.2174 24.25 15.9998 24.25ZM21.6665 24.25C22.4489 24.25 23.0832 24.8842 23.0832 25.6666C23.0832 26.4491 22.4489 27.0833 21.6665 27.0833C20.8841 27.0833 20.2498 26.4491 20.2498 25.6666C20.2498 24.8842 20.8841 24.25 21.6665 24.25ZM10.3332 18.5833C11.1156 18.5833 11.7498 19.2175 11.7498 20C11.7498 20.7824 11.1156 21.4166 10.3332 21.4166C9.55077 21.4166 8.9165 20.7824 8.9165 20C8.9165 19.2175 9.55077 18.5833 10.3332 18.5833ZM15.9998 18.5833C16.7823 18.5833 17.4165 19.2175 17.4165 20C17.4165 20.7824 16.7823 21.4166 15.9998 21.4166C15.2174 21.4166 14.5832 20.7824 14.5832 20C14.5832 19.2175 15.2174 18.5833 15.9998 18.5833ZM21.6665 18.5833C22.4489 18.5833 23.0832 19.2175 23.0832 20C23.0832 20.7824 22.4489 21.4166 21.6665 21.4166C20.8841 21.4166 20.2498 20.7824 20.2498 20C20.2498 19.2175 20.8841 18.5833 21.6665 18.5833ZM10.3332 12.9166C11.1156 12.9166 11.7498 13.5509 11.7498 14.3333C11.7498 15.1157 11.1156 15.75 10.3332 15.75C9.55077 15.75 8.9165 15.1157 8.9165 14.3333C8.9165 13.5509 9.55077 12.9166 10.3332 12.9166ZM15.9998 12.9166C16.7823 12.9166 17.4165 13.5509 17.4165 14.3333C17.4165 15.1157 16.7823 15.75 15.9998 15.75C15.2174 15.75 14.5832 15.1157 14.5832 14.3333C14.5832 13.5509 15.2174 12.9166 15.9998 12.9166ZM21.6665 12.9166C22.4489 12.9166 23.0832 13.5509 23.0832 14.3333C23.0832 15.1157 22.4489 15.75 21.6665 15.75C20.8841 15.75 20.2498 15.1157 20.2498 14.3333C20.2498 13.5509 20.8841 12.9166 21.6665 12.9166Z"
fill="currentcolor"
/>
</g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.286 24.2856C11.075 24.2856 11.7146 24.9252 11.7146 25.7142C11.7146 26.5032 11.075 27.1428 10.286 27.1428C9.49701 27.1428 8.85742 26.5032 8.85742 25.7142C8.85742 24.9252 9.49701 24.2856 10.286 24.2856ZM21.7146 24.2856C22.5036 24.2856 23.1431 24.9252 23.1431 25.7142C23.1431 26.5032 22.5036 27.1428 21.7146 27.1428C20.9256 27.1428 20.286 26.5032 20.286 25.7142C20.286 24.9252 20.9256 24.2856 21.7146 24.2856ZM10.286 18.5713C11.075 18.5713 11.7146 19.2109 11.7146 19.9999C11.7146 20.7889 11.075 21.4285 10.286 21.4285C9.49701 21.4285 8.85742 20.7889 8.85742 19.9999C8.85742 19.2109 9.49701 18.5713 10.286 18.5713ZM16.0003 18.5713C16.7893 18.5713 17.4289 19.2109 17.4289 19.9999C17.4289 20.7889 16.7893 21.4285 16.0003 21.4285C15.2113 21.4285 14.5717 20.7889 14.5717 19.9999C14.5717 19.2109 15.2113 18.5713 16.0003 18.5713ZM21.7146 18.5713C22.5036 18.5713 23.1431 19.2109 23.1431 19.9999C23.1431 20.7889 22.5036 21.4285 21.7146 21.4285C20.9256 21.4285 20.286 20.7889 20.286 19.9999C20.286 19.2109 20.9256 18.5713 21.7146 18.5713ZM10.286 12.8571C11.075 12.8571 11.7146 13.4966 11.7146 14.2856C11.7146 15.0746 11.075 15.7142 10.286 15.7142C9.49701 15.7142 8.85742 15.0746 8.85742 14.2856C8.85742 13.4966 9.49701 12.8571 10.286 12.8571ZM16.0003 12.8571C16.7893 12.8571 17.4289 13.4966 17.4289 14.2856C17.4289 15.0746 16.7893 15.7142 16.0003 15.7142C15.2113 15.7142 14.5717 15.0746 14.5717 14.2856C14.5717 13.4966 15.2113 12.8571 16.0003 12.8571Z"
fill="currentcolor"
/>
<path
d="M18 19.9999C18 18.8953 17.1046 17.9999 16 17.9999C14.8954 17.9999 14 18.8953 14 19.9999C14 21.1045 14.8954 21.9999 16 21.9999C17.1046 21.9999 18 21.1045 18 19.9999Z"
fill="currentcolor"
/>
<path
d="M14 18.9999C14 18.4476 14.4477 17.9999 15 17.9999H17C17.5523 17.9999 18 18.4476 18 18.9999V20.9999C18 21.5522 17.5523 21.9999 17 21.9999H15C14.4477 21.9999 14 21.5522 14 20.9999V18.9999Z"
fill="currentcolor"
/>
<path
d="M8.75 13.7499C8.75 13.1976 9.19772 12.7499 9.75 12.7499H10.75C11.3023 12.7499 11.75 13.1976 11.75 13.7499V14.7499C11.75 15.3022 11.3023 15.7499 10.75 15.7499H9.75C9.19772 15.7499 8.75 15.3022 8.75 14.7499V13.7499Z"
fill="currentcolor"
/>
<path
d="M33.8789 14.7578H35.0659L38.0938 22.2925L41.1152 14.7578H42.3086L38.5508 24H37.624L33.8789 14.7578ZM33.4917 14.7578H34.5391L34.7104 20.3945V24H33.4917V14.7578ZM41.6421 14.7578H42.6895V24H41.4707V20.3945L41.6421 14.7578ZM45.9395 17.1318V24H44.7588V17.1318H45.9395ZM44.6699 15.3101C44.6699 15.1196 44.7271 14.9588 44.8413 14.8276C44.9598 14.6965 45.1333 14.6309 45.3618 14.6309C45.5861 14.6309 45.7575 14.6965 45.876 14.8276C45.9987 14.9588 46.0601 15.1196 46.0601 15.3101C46.0601 15.492 45.9987 15.6486 45.876 15.7798C45.7575 15.9067 45.5861 15.9702 45.3618 15.9702C45.1333 15.9702 44.9598 15.9067 44.8413 15.7798C44.7271 15.6486 44.6699 15.492 44.6699 15.3101ZM48.999 18.5981V24H47.8247V17.1318H48.9355L48.999 18.5981ZM48.7197 20.3057L48.231 20.2866C48.2352 19.8169 48.305 19.3831 48.4404 18.9854C48.5758 18.5833 48.7663 18.2342 49.0117 17.938C49.2572 17.6418 49.5492 17.4132 49.8877 17.2524C50.2305 17.0874 50.6092 17.0049 51.0239 17.0049C51.3625 17.0049 51.6672 17.0514 51.938 17.1445C52.2088 17.2334 52.4395 17.3773 52.6299 17.5762C52.8245 17.7751 52.9727 18.0332 53.0742 18.3506C53.1758 18.6637 53.2266 19.0467 53.2266 19.4995V24H52.0459V19.4868C52.0459 19.1271 51.993 18.8394 51.8872 18.6235C51.7814 18.4035 51.627 18.2448 51.4238 18.1475C51.2207 18.0459 50.971 17.9951 50.6748 17.9951C50.3828 17.9951 50.1162 18.0565 49.875 18.1792C49.638 18.3019 49.4328 18.4712 49.2593 18.687C49.09 18.9028 48.9567 19.1504 48.8594 19.4297C48.7663 19.7048 48.7197 19.9967 48.7197 20.3057ZM56.2861 17.1318V24H55.1055V17.1318H56.2861ZM55.0166 15.3101C55.0166 15.1196 55.0737 14.9588 55.188 14.8276C55.3065 14.6965 55.48 14.6309 55.7085 14.6309C55.9328 14.6309 56.1042 14.6965 56.2227 14.8276C56.3454 14.9588 56.4067 15.1196 56.4067 15.3101C56.4067 15.492 56.3454 15.6486 56.2227 15.7798C56.1042 15.9067 55.9328 15.9702 55.7085 15.9702C55.48 15.9702 55.3065 15.9067 55.188 15.7798C55.0737 15.6486 55.0166 15.492 55.0166 15.3101ZM64.9951 15.5767L61.9355 24H60.6851L64.208 14.7578H65.0142L64.9951 15.5767ZM67.5596 24L64.4937 15.5767L64.4746 14.7578H65.2808L68.8164 24H67.5596ZM67.4009 20.5786V21.5815H62.2085V20.5786H67.4009ZM71.0508 18.4521V26.6406H69.8701V17.1318H70.9492L71.0508 18.4521ZM75.6782 20.5088V20.6421C75.6782 21.1414 75.619 21.6048 75.5005 22.0322C75.382 22.4554 75.2085 22.8236 74.98 23.1367C74.7557 23.4499 74.4785 23.6932 74.1484 23.8667C73.8184 24.0402 73.4396 24.127 73.0122 24.127C72.5763 24.127 72.1912 24.055 71.8569 23.9111C71.5226 23.7673 71.2391 23.5578 71.0063 23.2827C70.7736 23.0076 70.5874 22.6776 70.4478 22.2925C70.3123 21.9074 70.2192 21.4736 70.1685 20.9912V20.2803C70.2192 19.7725 70.3145 19.3175 70.4541 18.9155C70.5938 18.5135 70.7778 18.1707 71.0063 17.8872C71.2391 17.5994 71.5205 17.3815 71.8506 17.2334C72.1807 17.0811 72.5615 17.0049 72.9932 17.0049C73.4248 17.0049 73.8078 17.0895 74.1421 17.2588C74.4764 17.4238 74.7578 17.6608 74.9863 17.9697C75.2148 18.2786 75.3862 18.6489 75.5005 19.0806C75.619 19.508 75.6782 19.984 75.6782 20.5088ZM74.4976 20.6421V20.5088C74.4976 20.166 74.4616 19.8444 74.3896 19.5439C74.3177 19.2393 74.2056 18.9727 74.0532 18.7441C73.9051 18.5114 73.7147 18.3294 73.4819 18.1982C73.2492 18.0628 72.972 17.9951 72.6504 17.9951C72.3542 17.9951 72.096 18.0459 71.876 18.1475C71.6602 18.249 71.4761 18.3866 71.3237 18.5601C71.1714 18.7293 71.0465 18.924 70.9492 19.144C70.8561 19.3599 70.7863 19.5841 70.7397 19.8169V21.4609C70.8244 21.7572 70.9429 22.0365 71.0952 22.2988C71.2476 22.557 71.4507 22.7664 71.7046 22.9272C71.9585 23.0838 72.278 23.1621 72.6631 23.1621C72.9805 23.1621 73.2534 23.0965 73.4819 22.9653C73.7147 22.8299 73.9051 22.6458 74.0532 22.4131C74.2056 22.1803 74.3177 21.9137 74.3896 21.6133C74.4616 21.3086 74.4976 20.9849 74.4976 20.6421ZM78.3506 18.4521V26.6406H77.1699V17.1318H78.249L78.3506 18.4521ZM82.978 20.5088V20.6421C82.978 21.1414 82.9188 21.6048 82.8003 22.0322C82.6818 22.4554 82.5083 22.8236 82.2798 23.1367C82.0555 23.4499 81.7783 23.6932 81.4482 23.8667C81.1182 24.0402 80.7394 24.127 80.312 24.127C79.8761 24.127 79.491 24.055 79.1567 23.9111C78.8224 23.7673 78.5389 23.5578 78.3062 23.2827C78.0734 23.0076 77.8872 22.6776 77.7476 22.2925C77.6121 21.9074 77.519 21.4736 77.4683 20.9912V20.2803C77.519 19.7725 77.6143 19.3175 77.7539 18.9155C77.8936 18.5135 78.0776 18.1707 78.3062 17.8872C78.5389 17.5994 78.8203 17.3815 79.1504 17.2334C79.4805 17.0811 79.8613 17.0049 80.293 17.0049C80.7246 17.0049 81.1076 17.0895 81.4419 17.2588C81.7762 17.4238 82.0576 17.6608 82.2861 17.9697C82.5146 18.2786 82.686 18.6489 82.8003 19.0806C82.9188 19.508 82.978 19.984 82.978 20.5088ZM81.7974 20.6421V20.5088C81.7974 20.166 81.7614 19.8444 81.6895 19.5439C81.6175 19.2393 81.5054 18.9727 81.353 18.7441C81.2049 18.5114 81.0145 18.3294 80.7817 18.1982C80.549 18.0628 80.2718 17.9951 79.9502 17.9951C79.654 17.9951 79.3958 18.0459 79.1758 18.1475C78.96 18.249 78.7759 18.3866 78.6235 18.5601C78.4712 18.7293 78.3464 18.924 78.249 19.144C78.1559 19.3599 78.0861 19.5841 78.0396 19.8169V21.4609C78.1242 21.7572 78.2427 22.0365 78.395 22.2988C78.5474 22.557 78.7505 22.7664 79.0044 22.9272C79.2583 23.0838 79.5778 23.1621 79.9629 23.1621C80.2803 23.1621 80.5532 23.0965 80.7817 22.9653C81.0145 22.8299 81.2049 22.6458 81.353 22.4131C81.5054 22.1803 81.6175 21.9137 81.6895 21.6133C81.7614 21.3086 81.7974 20.9849 81.7974 20.6421Z"
fill="currentcolor"
/>
</svg>
);
}
export default Logo;
import React from "react";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg";
}
export const Avatar: React.FC<AvatarProps> = ({
size = "md",
className = "",
src,
alt = "User Avatar",
...props
}) => {
const sizeStyles = {
sm: "w-10 h-10 border-2",
md: "w-14 h-14 border-[3px]",
lg: "w-20 h-20 border-4",
};
const defaultAvatar = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%238B7CF6'><circle cx='12' cy='8' r='4'/><path d='M12 14c-6.1 0-8 4-8 4v2h16v-2s-1.9-4-8-4z'/></svg>";
return (
<div
className={`
rounded-full bg-clay-surface border-white shadow-clay-raised overflow-hidden inline-block flex-shrink-0
${sizeStyles[size]}
${className}
`}
>
<img
src={src || defaultAvatar}
alt={alt}
className="w-full h-full object-cover rounded-full"
{...props}
/>
</div>
);
};
import React from "react";
export type BadgeType = "primary" | "income" | "expense" | "warning" | "info";
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
type?: BadgeType;
}
export const Badge: React.FC<BadgeProps> = ({
children,
type = "primary",
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]",
};
return (
<span
className={`
inline-flex items-center px-3 py-1 font-nunito font-bold text-xs rounded-full
${typeStyles[type]}
${className}
`}
{...props}
>
{children}
</span>
);
};
import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
shape?: "clay" | "pill";
fullWidth?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = "primary",
shape = "clay",
fullWidth = false,
className = "",
disabled,
...props
}) => {
// 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]",
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]",
};
// Border radius shapes
const shapeStyles = {
clay: "rounded-clay",
pill: "rounded-full",
};
const disabledStyles = disabled
? "opacity-50 cursor-not-allowed transform-none shadow-none pointer-events-none"
: "cursor-pointer";
const widthStyle = fullWidth ? "w-full" : "";
return (
<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
${variantStyles[variant]}
${shapeStyles[shape]}
${disabledStyles}
${widthStyle}
${className}
`}
disabled={disabled}
{...props}
>
{children}
</button>
);
};
import React from "react";
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
hoverable?: boolean;
}
export const Card: React.FC<CardProps> = ({
children,
hoverable = false,
className = "",
...props
}) => {
return (
<div
className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6
transition-all duration-200 ease-in-out border border-white/40
${hoverable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className}
`}
{...props}
>
{children}
</div>
);
};
import React from "react";
import { BadgeType } from "./Badge";
export interface IconWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
type?: BadgeType;
size?: "sm" | "md" | "lg";
}
export const IconWrapper: React.FC<IconWrapperProps> = ({
children,
type = "primary",
size = "md",
className = "",
...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",
};
const sizeStyles = {
sm: "w-10 h-10 rounded-clay-sm text-sm",
md: "w-12 h-12 rounded-clay text-base",
lg: "w-16 h-16 rounded-clay-lg text-2xl",
};
return (
<div
className={`
inline-flex items-center justify-center shadow-clay-raised border-white
transition-all duration-200 ease-in-out hover:scale-105
${typeStyles[type]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{children}
</div>
);
};
import React from "react";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input: React.FC<InputProps> = ({
label,
error,
className = "",
id,
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="flex flex-col gap-2 w-full">
{label && (
<label htmlFor={inputId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label}
</label>
)}
<input
id={inputId}
className={`
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
rounded-clay-sm shadow-clay-pressed border border-transparent
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
/>
{error && (
<span className="font-nunito text-xs text-clay-expense px-1">
{error}
</span>
)}
</div>
);
};
import React, { useEffect } from "react";
import { CloseIcon } from "./icons";
import { Button } from "./Button";
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
}) => {
// Prevent background scrolling when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!isOpen) return null;
return (
<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"
onClick={onClose}
/>
{/* Modal Container */}
<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
transform transition-all duration-300 ease-out translate-y-0 sm:scale-100
animate-slide-up
"
>
{/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3>
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px]"
>
<CloseIcon size={16} />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto max-h-[60vh] py-2 font-nunito text-clay-text">
{children}
</div>
{/* Footer */}
{footer && (
<div className="flex justify-end gap-3 pt-3 border-t border-clay-text-muted/10">
{footer}
</div>
)}
</div>
</div>
);
};
import React from "react";
import { BadgeType } from "./Badge";
export interface ProgressBarProps {
value: number; // 0 to 100
type?: BadgeType;
height?: string; // Tailwind height class, e.g. "h-4"
className?: string;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({
value,
type = "primary",
height = "h-4.5",
className = "",
}) => {
// Cap value between 0 and 100
const percentage = Math.max(0, Math.min(100, value));
// 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",
};
return (
<div className={`w-full flex flex-col gap-1.5 ${className}`}>
<div
className={`
w-full bg-clay-bg rounded-full shadow-clay-pressed overflow-hidden p-0.5 border border-clay-text/5
${height}
`}
>
<div
className={`
h-full rounded-full transition-all duration-300 ease-out shadow-[inset_-2px_-2px_4px_rgba(0,0,0,0.15)]
${gradientStyles[type]}
`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};
import React from "react";
import { ChevronDownIcon } from "./icons";
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: SelectOption[];
error?: string;
}
export const Select: React.FC<SelectProps> = ({
label,
options,
error,
className = "",
id,
...props
}) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="flex flex-col gap-2 w-full relative">
{label && (
<label htmlFor={selectId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label}
</label>
)}
<div className="relative w-full">
<select
id={selectId}
className={`
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
transition-all duration-150 ease-in-out
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-clay-surface text-clay-text">
{option.label}
</option>
))}
</select>
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-clay-text-muted">
<ChevronDownIcon size={16} />
</div>
</div>
{error && (
<span className="font-nunito text-xs text-clay-expense px-1">
{error}
</span>
)}
</div>
);
};
import React from "react";
export interface TabItem {
key: string;
label: string;
}
export interface TabsProps {
tabs: TabItem[];
activeTab: string;
onChange: (key: string) => void;
className?: string;
}
export const Tabs: React.FC<TabsProps> = ({
tabs,
activeTab,
onChange,
className = "",
}) => {
return (
<div
className={`
bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none
${className}
`}
>
{tabs.map((tab) => {
const isActive = tab.key === activeTab;
return (
<button
key={tab.key}
onClick={() => onChange(tab.key)}
className={`
flex-1 text-center py-2 px-4 text-sm font-baloo font-semibold rounded-full
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"
: "text-clay-text-muted hover:text-clay-text bg-transparent"
}
`}
>
{tab.label}
</button>
);
})}
</div>
);
};
import React from "react";
export interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number;
}
// Helper gradient definitions for Claymorphic 3D styling
export const IconGradients: React.FC = () => (
<svg className="absolute w-0 h-0" width="0" height="0">
<defs>
<linearGradient id="clay-grad-primary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#A78BFA" />
<stop offset="100%" stopColor="#8B7CF6" />
</linearGradient>
<linearGradient id="clay-grad-income" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#6EE7B7" />
<stop offset="100%" stopColor="#34D399" />
</linearGradient>
<linearGradient id="clay-grad-expense" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FDA4AF" />
<stop offset="100%" stopColor="#FB7185" />
</linearGradient>
<linearGradient id="clay-grad-warning" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FDE047" />
<stop offset="100%" stopColor="#FBBF24" />
</linearGradient>
<linearGradient id="clay-grad-info" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#93C5FD" />
<stop offset="100%" stopColor="#60A5FA" />
</linearGradient>
{/* 3D clay shadow filter */}
<filter id="clay-3d-shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" floodColor="#2D2A45" floodOpacity="0.15" />
</filter>
</defs>
</svg>
);
// Home (Dashboard)
export const HomeIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="url(#clay-grad-primary)" stroke="#6D5BD0" />
<polyline points="9 22 9 12 15 12 15 22" stroke="#2D2A45" />
</svg>
);
// Wallet (Ví)
export const WalletIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<rect x="2" y="4" width="20" height="16" rx="4" fill="url(#clay-grad-info)" stroke="#60A5FA" />
<path d="M16 11h6v2h-6z" fill="#FFF" stroke="#2D2A45" />
<circle cx="18" cy="12" r="2" fill="#FBBF24" stroke="#2D2A45" />
</svg>
);
// Transactions (Giao dịch)
export const TransactionIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<rect x="3" y="4" width="18" height="18" rx="4" fill="url(#clay-grad-primary)" stroke="#6D5BD0" />
<line x1="16" y1="2" x2="16" y2="6" stroke="#2D2A45" />
<line x1="8" y1="2" x2="8" y2="6" stroke="#2D2A45" />
<line x1="3" y1="10" x2="21" y2="10" stroke="#2D2A45" />
<path d="M8 14h8M8 18h5" stroke="#2D2A45" />
</svg>
);
// Transfer (Chuyển khoản)
export const TransferIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M17 10H3l4-4M7 14h14l-4 4" stroke="currentColor" />
<circle cx="12" cy="12" r="9" fill="url(#clay-grad-warning)" fillOpacity="0.2" stroke="#FBBF24" />
</svg>
);
// Budget (Ngân sách)
export const BudgetIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<circle cx="12" cy="12" r="10" fill="url(#clay-grad-warning)" stroke="#FBBF24" />
<path d="M12 6v12M15 9H10a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H9" stroke="#2D2A45" />
</svg>
);
// Saving Goal (Mục tiêu tiết kiệm)
export const SavingGoalIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="url(#clay-grad-income)" stroke="#34D399" />
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="#2D2A45" />
</svg>
);
// Report (Báo cáo)
export const ReportIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M18 20V10M12 20V4M6 20v-6" stroke="currentColor" />
<rect x="2" y="2" width="20" height="20" rx="4" fill="url(#clay-grad-info)" fillOpacity="0.25" stroke="#60A5FA" />
</svg>
);
// AI Assistant
export const AIAssistantIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<rect x="3" y="11" width="18" height="10" rx="4" fill="url(#clay-grad-primary)" stroke="#8B7CF6" />
<circle cx="12" cy="5" r="3" fill="url(#clay-grad-warning)" stroke="#FBBF24" />
<path d="M12 8v3" stroke="#2D2A45" />
<line x1="8" y1="16" x2="9" y2="16" stroke="#FFF" />
<line x1="15" y1="16" x2="16" y2="16" stroke="#FFF" />
<path d="M9 19s1.5 1.5 3 1.5 3-1.5 3-1.5" stroke="#FFF" />
</svg>
);
// Notification
export const NotificationIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0" fill="url(#clay-grad-warning)" stroke="#FBBF24" />
</svg>
);
// User Profile
export const UserIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" fill="url(#clay-grad-primary)" fillOpacity="0.3" stroke="#8B7CF6" />
<circle cx="12" cy="7" r="4" fill="url(#clay-grad-primary)" stroke="#8B7CF6" />
</svg>
);
// Plus / Add
export const PlusIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
// Close / X
export const CloseIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
// Arrow Left / Back
export const BackIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
// Chevron Down
export const ChevronDownIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="6 9 12 15 18 9" />
</svg>
);
// Chevron Right
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
<polyline points="9 18 15 12 9 6" />
</svg>
);
// Income Category Icon Example (Food, Shopping, etc.)
export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<circle cx="12" cy="12" r="10" fill="url(#clay-grad-expense)" stroke="#FB7185" />
<path d="M12 6v6h6" stroke="#2D2A45" />
</svg>
);
body {
font-family: 'Nunito', sans-serif;
background-color: #F3F0FA; /* clay-bg */
color: #2D2A45; /* clay-text */
-webkit-font-smoothing: antialiased;
}
/* Custom style override for ZMP Page containers */
.page {
padding: 16px 16px 96px 16px;
background-color: #F3F0FA; /* clay-bg */
min-height: 100vh;
}
.clay-title-h1 {
font-family: 'Baloo 2', sans-serif;
font-weight: 700;
font-size: 32px;
line-height: 1.2;
color: #2D2A45; /* clay-text */
}
.clay-title-h2 {
font-family: 'Baloo 2', sans-serif;
font-weight: 600;
font-size: 24px;
line-height: 1.3;
color: #2D2A45;
}
.clay-title-h3 {
font-family: 'Baloo 2', sans-serif;
font-weight: 600;
font-size: 18px;
line-height: 1.4;
color: #2D2A45;
}
.clay-body {
font-family: 'Nunito', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.5;
color: #2D2A45;
}
.clay-caption {
font-family: 'Nunito', sans-serif;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
color: #8B85A6; /* clay-text-muted */
}
/* Custom scrollbars inside Claymorphism components */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(163, 150, 208, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(163, 150, 208, 0.5);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: content:"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#F3F0FA" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<title>Sổ tay Chi tiêu & Báo cáo Tài chính</title>
<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">
</head>
<body>
<div id="app"></div>
<!-- built script files will be auto injected -->
<script type="module" src="/app.ts"></script>
</body>
</html>
import React from "react";
import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
function HomePage() {
const navigate = useNavigate();
return (
<Page className="page flex flex-col justify-between py-8">
<Header title="FinWise Mini App" showBackIcon={false} />
<IconGradients />
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
{/* Logo/Avatar Area */}
<div className="relative">
<Avatar size="lg" className="border-clay-primary" />
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white">
<AIAssistantIcon size={20} />
</div>
</div>
{/* Text Area */}
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1>
<h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2>
<p className="clay-caption max-w-xs mx-auto">
Ứng dụng quản lý tài chính cá nhân thông minh tích hợp Trợ lý AI trên nền tảng Zalo Mini App.
</p>
</div>
{/* Info Card */}
<Card className="w-full max-w-sm">
<h3 className="font-baloo font-bold text-clay-text text-base mb-1">Thiết kế Claymorphism</h3>
<p className="text-xs text-clay-text-muted">
Giao diện đã được cấu hình toàn bộ theo phong cách đất sét 3D mềm mại, bo góc bo tròn chunky và bóng đổ kép dịu mắt.
</p>
</Card>
</div>
{/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto">
<Button
variant="primary"
fullWidth
onClick={() => navigate("/style-guide")}
>
Khám phá Style Guide 🚀
</Button>
</div>
</Page>
);
}
export default HomePage;
This diff is collapsed.
<svg width="360" height="800" viewBox="0 0 360 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<g filter="url(#filter0_f_2812_2012)">
<circle cx="65" cy="210" r="177" fill="#FFAE00"/>
</g>
<g filter="url(#filter1_f_2812_2012)">
<circle cx="239" cy="400" r="276" fill="#008CFF"/>
</g>
<g filter="url(#filter2_f_2812_2012)">
<circle cx="23" cy="577" r="177" fill="#FF0077"/>
</g>
</g>
<defs>
<filter id="filter0_f_2812_2012" x="-312" y="-167" width="754" height="754" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2812_2012"/>
</filter>
<filter id="filter1_f_2812_2012" x="-237" y="-76" width="952" height="952" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2812_2012"/>
</filter>
<filter id="filter2_f_2812_2012" x="-354" y="200" width="754" height="754" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_2812_2012"/>
</filter>
</defs>
</svg>
module.exports = {
darkMode: ["selector", '[zaui-theme="dark"]'],
purge: {
enabled: true,
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)",
},
},
borderRadius: {
"clay-sm": "16px",
"clay": "24px",
"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)",
},
fontFamily: {
baloo: ["'Baloo 2'", "sans-serif"],
nunito: ["Nunito", "sans-serif"],
},
},
},
plugins: [],
};
{
"compilerOptions": {
"noEmit": true,
"target": "es6",
"module": "esnext",
"noImplicitAny": false,
"preserveConstEnums": true,
"jsx": "react-jsx",
"lib": ["dom", "es5", "es6", "es7", "es2017"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"skipLibCheck": true,
"declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"strict": true,
"strictFunctionTypes": false,
"pretty": true,
"removeComments": true,
"sourceMap": true,
"resolveJsonModule": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"include": ["src"]
}
import { defineConfig } from "vite";
import zaloMiniApp from "zmp-vite-plugin";
import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/
export default () => {
return defineConfig({
root: "./src",
base: "",
plugins: [zaloMiniApp(), react()],
build: {
assetsInlineLimit: 0,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
};
{
"cwd": "/Users/lap15182-local/Documents/zmp-blank-templates",
"name": "zmp-blank-templates",
"framework": "react-typescript",
"cssPreProcessor": "scss",
"includeTailwind": true,
"package": "zmp-ui",
"stateManagement": "jotai",
"newProject": true,
"template": "single-view",
"theming": {
"customColor": false,
"color": "#007aff",
"darkTheme": false,
"iconFonts": true,
"fillBars": false,
"useUiKits": true
}
}
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