Tự Tay Xây Dựng Dự Án Từ Con Số 0 — Từng Bước Một 🚀¶
Bài viết này sẽ "cầm tay chỉ việc" giúp bạn tự xây dựng một ứng dụng quản lý quỹ nhóm (group-accounting) từ lúc chưa có gì. Cứ bình tĩnh làm theo từng bước nhé. Sau khi hoàn thành, bạn sẽ sở hữu một "combo" xịn sò:
- API (Bộ não xử lý): Dùng Cloudflare Workers + Hono (nhỏ mà có võ) + Database D1.
- Web (Giao diện người dùng): React 18 + Vite (chạy siêu nhanh) + TanStack Query + Zustand (quản lý trạng thái) + Tailwind (làm đẹp).
- Bảo mật (Auth): Tự làm hệ thống đăng nhập bằng JWT và mã hóa mật khẩu PBKDF2 (không cần cài thêm thư viện phức tạp).
- Lên sóng (Deploy): Đưa tất cả lên Cloudflare hoàn toàn miễn phí.
Bắt đầu thôi nào!
Bước 1 — Khởi Động & Chuẩn Bị Bồ Đồ Nghề 🛠️¶
1.1 Cài đặt công cụ cần thiết¶
Đầu tiên, hãy chắc chắn máy bạn đã có Node.js. Mở Terminal (cửa sổ lệnh) lên và gõ:
# Kiểm tra phiên bản Node.js (cần bản 18 trở lên nhé)
node --version
npm --version
# Cài đặt Wrangler - Công cụ giao tiếp với Cloudflare
npm install -g wrangler
# Nhớ đăng ký một tài khoản Cloudflare (miễn phí) trước tại:
# [https://dash.cloudflare.com/sign-up](https://dash.cloudflare.com/sign-up)
# Đăng nhập vào Cloudflare từ máy tính của bạn
wrangler login
# → Trình duyệt sẽ mở ra để bạn xác nhận. Sau khi xong, kiểm tra lại bằng lệnh:
wrangler whoami
1.2 Tạo "Ngôi nhà chung" (Monorepo)¶
Chúng ta sẽ gom cả phần API và Web vào chung một thư mục cho dễ quản lý. Mở Terminal và dán từng lệnh dưới đây để tạo cấu trúc thư mục nhé:
mkdir my-fund-app && cd my-fund-app
# Tạo file cấu hình chính
cat > package.json << 'EOF'
{
"name": "my-fund-app",
"private": true,
"workspaces": ["api", "web"],
"scripts": {
"dev:api": "cd api && wrangler dev",
"dev:web": "cd web && npm run dev"
}
}
EOF
# Tạo file ẩn để git không tải nhầm rác lên mạng
cat > .gitignore << 'EOF'
node_modules/
dist/
.wrangler/
.dev.vars
*.local
EOF
# Lưu lại lịch sử
git init
git add .
git commit -m "init: Bắt đầu dự án thôi!"
Bước 2 — Xây Dựng Bộ Não (Setup API) 🧠¶
2.1 Khởi tạo dự án API¶
mkdir api && cd api
# Khởi tạo package.json và cài các thư viện cần thiết
npm init -y
npm install hono
npm install -D @cloudflare/workers-types typescript wrangler
2.2 Cấu hình TypeScript¶
TypeScript giúp nhắc lỗi cho chúng ta ngay khi gõ code.
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*"]
}
EOF
2.3 Tạo Cơ sở dữ liệu (D1 Database)¶
Cùng xin Cloudflare một cái kho để lưu dữ liệu nhé:
wrangler d1 create my-fund-db
# Khi thành công, nó sẽ hiện ra thông báo kèm dòng database_id.
# Hãy copy cái mã database_id dài ngoằng đó lại nhé!
2.4 Cấu hình cho Cloudflare Worker (wrangler.toml)¶
Hãy thay thế PASTE_YOUR_DATABASE_ID_HERE bằng cái mã bạn vừa copy ở trên.
cat > wrangler.toml << 'EOF'
name = "my-fund-api"
main = "src/index.ts"
compatibility_date = "2024-07-25"
compatibility_flags = ["nodejs_compat"]
[vars]
FRONTEND_URL = "http://localhost:5173"
[[d1_databases]]
binding = "DB"
database_name = "my-fund-db"
database_id = "PASTE_YOUR_DATABASE_ID_HERE"
EOF
2.5 Tạo chìa khóa bí mật để chạy ở máy (Local)¶
cat > .dev.vars << 'EOF'
JWT_SECRET=local-dev-secret-minimum-32-chars-long
FRONTEND_URL=http://localhost:5173
EOF
Mẹo nhỏ về bảo mật nâng cao
Trong dự án thật, người ta hay gắn thêm reCAPTCHA (Turnstile của Cloudflare) để chống bị spam đăng nhập. Nhưng để dễ hiểu, bài này chúng ta cứ làm chạy được flow cơ bản đã nhé. Bạn có thể tìm hiểu thêm sau.
2.6 Định nghĩa các kiểu dữ liệu (Types)¶
mkdir src
cat > src/types.ts << 'EOF'
export interface Env {
DB: D1Database;
JWT_SECRET: string;
FRONTEND_URL: string;
}
export interface JwtPayload {
userId: string;
role: 'super_admin' | 'user';
exp: number; // Thời gian hết hạn tính bằng GIÂY
}
EOF
2.7 Các công cụ tiện ích (Mã hóa mật khẩu, tạo token)¶
Đoạn code này hơi dài một xíu, nó phụ trách việc tạo mã ID ngẫu nhiên, giấu mật khẩu (hash) và cấp phát thẻ chứng minh (JWT).
cat > src/utils.ts << 'EOF'
import type { JwtPayload } from './types';
// Hàm tạo mã ID ngẫu nhiên (Nanoid)
export function nanoid(size = 21): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = crypto.getRandomValues(new Uint8Array(size));
return Array.from(bytes, b => chars[b % chars.length]).join('');
}
// Băm mật khẩu ra cho an toàn (PBKDF2)
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, key, 256
);
const toHex = (buf: Uint8Array) => Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('');
return `pbkdf2:${toHex(salt)}:${toHex(new Uint8Array(bits))}`;
}
// Kiểm tra xem mật khẩu nhập vào có khớp không
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
try {
const [, saltHex, hashHex] = stored.split(':');
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map(h => parseInt(h, 16)));
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, ['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, key, 256
);
const computed = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
return computed === hashHex;
} catch { return false; }
}
// Ký thẻ thông hành (JWT)
export async function signJwt(
payload: Omit<JwtPayload, 'exp'>,
secret: string,
): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // 30 ngày mới hết hạn
const fullPayload: JwtPayload = { ...payload, exp };
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/=/g, '');
const body = btoa(JSON.stringify(fullPayload)).replace(/=/g, '');
const data = `${header}.${body}`;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
const sig = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return `${data}.${sig}`;
}
// Xác minh thẻ thông hành (JWT)
export async function verifyJwt(token: string, secret: string): Promise<JwtPayload | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [header, body, sig] = parts;
const data = `${header}.${body}`;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const sigBytes = Uint8Array.from(
atob(sig.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0),
);
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(data));
if (!valid) return null;
const payload: JwtPayload = JSON.parse(atob(body));
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}
export async function hashToken(token: string): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
EOF
2.8 Trạm gác chính (File chạy gốc)¶
File này sẽ đóng vai trò như một anh bảo vệ, quyết định ai được vào đâu.
cat > src/index.ts << 'EOF'
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Env, JwtPayload } from './types';
import { verifyJwt } from './utils';
// Mấy file này ta sẽ tạo ở Bước 3 nhé
import { auth } from './routes/auth';
import { groups } from './routes/groups';
type Variables = { jwtPayload: JwtPayload };
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
// Chống lỗi CORS khi Frontend gọi API
app.use('*', cors({
origin: (origin, c) => {
const allowed = [c.env.FRONTEND_URL, 'http://localhost:5173'];
return allowed.includes(origin) ? origin : allowed[0];
},
allowHeaders: ['Authorization', 'Content-Type'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
}));
// Chốt kiểm tra vé (JWT Middleware)
export const jwtMiddleware = async (c: any, next: () => Promise<void>) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) return c.json({ error: 'Chưa đăng nhập nè!' }, 401);
const payload = await verifyJwt(authHeader.slice(7), c.env.JWT_SECRET);
if (!payload) return c.json({ error: 'Phiên đăng nhập hết hạn rồi' }, 401);
c.set('jwtPayload', payload);
await next();
};
// Hàm test xem server có đang thở không
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
// Các đường dẫn cho phép vào tự do (đăng nhập, đăng ký)
app.route('/auth', auth);
// Các đường dẫn cần phải có vé (đã đăng nhập)
app.use('/groups', jwtMiddleware);
app.use('/groups/:groupId', jwtMiddleware);
app.route('/groups', groups);
export default app;
EOF
Đừng hoảng nếu có lỗi đỏ đỏ
Lúc này TypeScript sẽ báo lỗi là chưa thấy file ./routes/auth — chuyện bình thường thôi, vì mình chưa tạo mà. Làm tiếp Bước 3 là nó tự hết!
Bước 3 — Dựng Khung Cơ Sở Dữ Liệu 🗄️¶
3.1 Tạo kịch bản chia bảng (Migration)¶
Phải nói cho Database biết nó cần tạo những bảng nào để lưu dữ liệu.
mkdir migrations
cat > migrations/0001_initial.sql << 'EOF'
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
invite_code TEXT NOT NULL UNIQUE,
currency TEXT NOT NULL DEFAULT 'VND',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS group_members (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(group_id, user_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
member_id TEXT NOT NULL REFERENCES group_members(id),
type TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
EOF
# Chạy lệnh này để nhét kịch bản vào Database đang chạy trên máy bạn
wrangler d1 migrations apply my-fund-db --local
3.2 Kiểm tra xem bảng đã tạo thành công chưa¶
wrangler d1 execute my-fund-db --local --command ".tables"
# Nếu hiện ra: group_members groups transactions users thì xin chúc mừng! 🎉
3.3 Tạo chức năng Đăng ký, Đăng nhập (Auth route)¶
mkdir -p src/routes
cat > src/routes/auth.ts << 'EOF'
import { Hono } from 'hono';
import type { Env, JwtPayload } from '../types';
import { nanoid, hashPassword, verifyPassword, signJwt } from '../utils';
type Variables = { jwtPayload: JwtPayload };
export const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
// GET /auth/setup-status — Kiểm tra xem đã ai làm sếp (super_admin) chưa
auth.get('/setup-status', async (c) => {
const existing = await c.env.DB.prepare(
`SELECT id FROM users WHERE role = 'super_admin' LIMIT 1`
).first();
return c.json({ isSetup: !!existing });
});
// POST /auth/setup — Khởi tạo sếp lớn lần đầu
auth.post('/setup', async (c) => {
const existing = await c.env.DB.prepare(
`SELECT id FROM users WHERE role = 'super_admin' LIMIT 1`
).first();
if (existing) return c.json({ error: 'Hệ thống đã có chủ rồi nha!' }, 409);
const body = await c.req.json<{ email: string; name: string; password: string }>();
if (!body.email?.trim() || !body.name?.trim() || !body.password) {
return c.json({ error: 'Nhập thiếu thông tin rồi bạn ơi' }, 400);
}
if (body.password.length < 8) {
return c.json({ error: 'Mật khẩu phải từ 8 ký tự trở lên' }, 400);
}
const userId = nanoid();
const passwordHash = await hashPassword(body.password);
const email = body.email.trim().toLowerCase();
await c.env.DB.prepare(
`INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, 'super_admin')`
).bind(userId, email, passwordHash, body.name.trim()).run();
const accessToken = await signJwt({ userId, role: 'super_admin' }, c.env.JWT_SECRET);
return c.json({
accessToken, userId, email, name: body.name.trim(), role: 'super_admin', groups: [],
}, 201);
});
// POST /auth/login — Đăng nhập
auth.post('/login', async (c) => {
const body = await c.req.json<{ email: string; password: string }>();
if (!body.email?.trim() || !body.password) {
return c.json({ error: 'Cần nhập đủ email và mật khẩu nhé' }, 400);
}
const user = await c.env.DB.prepare(
`SELECT id, email, name, role, password_hash FROM users WHERE email = ?`
).bind(body.email.trim().toLowerCase()).first<{
id: string; email: string; name: string; role: 'super_admin' | 'user'; password_hash: string;
}>();
if (!user) return c.json({ error: 'Sai email hoặc mật khẩu' }, 401);
const ok = await verifyPassword(body.password, user.password_hash);
if (!ok) return c.json({ error: 'Sai email hoặc mật khẩu' }, 401);
const { results: groups } = await c.env.DB.prepare(
`SELECT g.id, g.slug, g.name, g.currency, gm.role as member_role
FROM groups g JOIN group_members gm ON g.id = gm.group_id
WHERE gm.user_id = ?`
).bind(user.id).all();
const accessToken = await signJwt({ userId: user.id, role: user.role }, c.env.JWT_SECRET);
return c.json({
accessToken, userId: user.id, email: user.email, name: user.name, role: user.role, groups,
});
});
EOF
3.4 Tạo chức năng Quản lý Nhóm quỹ (Groups route)¶
cat > src/routes/groups.ts << 'EOF'
import { Hono } from 'hono';
import type { Env, JwtPayload } from '../types';
import { nanoid } from '../utils';
type Variables = { jwtPayload: JwtPayload };
export const groups = new Hono<{ Bindings: Env; Variables: Variables }>();
// Lấy danh sách các nhóm mình tham gia
groups.get('/', async (c) => {
const { userId } = c.get('jwtPayload');
const { results } = await c.env.DB.prepare(
`SELECT g.id, g.slug, g.name, g.currency, gm.role as member_role
FROM groups g JOIN group_members gm ON g.id = gm.group_id
WHERE gm.user_id = ?`
).bind(userId).all();
return c.json(results);
});
// Tạo một nhóm quỹ mới (Người tạo nghiễm nhiên thành Admin nhóm)
groups.post('/', async (c) => {
const { userId } = c.get('jwtPayload');
const body = await c.req.json<{ name: string; currency?: string }>();
if (!body.name?.trim()) return c.json({ error: 'Thiếu tên nhóm kìa bạn' }, 400);
const groupId = nanoid();
const memberId = nanoid();
const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + nanoid(6).toLowerCase();
const inviteCode = nanoid(8).toUpperCase();
// Dùng batch để đảm bảo: Lỗi là nghỉ hết, không tạo ra rác
await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO groups (id, slug, name, invite_code, currency) VALUES (?, ?, ?, ?, ?)`
).bind(groupId, slug, body.name.trim(), inviteCode, body.currency ?? 'VND'),
c.env.DB.prepare(
`INSERT INTO group_members (id, group_id, user_id, role) VALUES (?, ?, ?, 'admin')`
).bind(memberId, groupId, userId),
]);
return c.json({ id: groupId, slug, name: body.name.trim(), inviteCode }, 201);
});
// Xem chi tiết một nhóm cụ thể
groups.get('/:groupId', async (c) => {
const { userId } = c.get('jwtPayload');
const groupId = c.req.param('groupId');
const member = await c.env.DB.prepare(
`SELECT id, role FROM group_members WHERE group_id = ? AND user_id = ?`
).bind(groupId, userId).first();
if (!member) return c.json({ error: 'Ủa, bạn đâu có trong nhóm này?' }, 403);
const group = await c.env.DB.prepare(
`SELECT id, slug, name, description, currency, invite_code FROM groups WHERE id = ?`
).bind(groupId).first();
if (!group) return c.json({ error: 'Nhóm này bốc hơi rồi' }, 404);
return c.json({ ...group, currentMember: member });
});
EOF
Bước 4 — Xây Nhà Mặt Tiền (Setup Web/Frontend) 🎨¶
Phần não bộ (API) hòm hòm rồi, giờ qua làm giao diện (Web) cho nó đẹp.
4.1 Tạo khung React bằng Vite¶
cd .. # Lùi lại thư mục gốc (my-fund-app)
npm create vite@latest web -- --template react-swc-ts
cd web
npm install
4.2 Lắp các "đồ chơi" cần thiết¶
npm install react-router-dom @tanstack/react-query zustand dayjs
npm install -D tailwindcss postcss autoprefixer wrangler
npx tailwindcss init -p
4.3 Setup CSS (Tailwind)¶
Chỉnh lại file tailwind.config.js:
// Mở file tailwind.config.js và sửa thành vầy:
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}
Và đè nội dung file src/index.css:
/* src/index.css — Xóa hết cái cũ, bỏ 3 dòng này vô */
@tailwind base;
@tailwind components;
@tailwind utilities;
4.4 Cấu hình đường kết nối¶
4.5 Mẹo nhỏ cho React chạy mượt trên Cloudflare¶
4.6 Viết các hàm gọi API (Để Web nói chuyện với API)¶
mkdir -p src/lib
cat > src/lib/api.ts << 'EOF'
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8787';
interface GroupSummary {
id: string; slug: string; name: string; currency: string;
member_role: 'admin' | 'sub_admin' | 'member';
}
interface LoginResponse {
accessToken: string; userId: string; email: string; name: string;
role: 'super_admin' | 'user'; groups: GroupSummary[];
}
// Hàm chuẩn bị gửi yêu cầu
async function request<T>(
path: string,
options: RequestInit & { token?: string } = {}
): Promise<T> {
const { token, ...init } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(init.headers as Record<string, string>),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
const data = await res.json();
if (!res.ok) throw new Error((data as any).error ?? `Lỗi HTTP ${res.status}`);
return data as T;
}
export const api = {
health: () => request<{ status: string }>('/health'),
auth: {
setupStatus: () => request<{ isSetup: boolean }>('/auth/setup-status'),
setup: (body: { email: string; name: string; password: string }) =>
request<LoginResponse>('/auth/setup', { method: 'POST', body: JSON.stringify(body) }),
login: (body: { email: string; password: string }) =>
request<LoginResponse>('/auth/login', { method: 'POST', body: JSON.stringify(body) }),
},
groups: {
list: (token: string) => request<GroupSummary[]>('/groups', { token }),
create: (body: { name: string; currency?: string }, token: string) =>
request<{ id: string; slug: string; name: string; inviteCode: string }>(
'/groups', { method: 'POST', body: JSON.stringify(body), token },
),
},
};
EOF
4.7 Nơi lưu trữ thông tin đăng nhập (Zustand)¶
mkdir -p src/store
cat > src/store/authStore.ts << 'EOF'
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
//... (Bỏ qua đoạn định nghĩa dài dòng nha, copy y chang code cũ vào đây nhé) ...
interface GroupSummary {
id: string; slug: string; name: string; currency: string;
member_role: 'admin' | 'sub_admin' | 'member';
}
interface AuthState {
accessToken: string | null; userId: string | null; email: string | null;
name: string | null; role: 'super_admin' | 'user' | null; groups: GroupSummary[];
setAuth: (data: { accessToken: string; userId: string; email: string; name: string; role: 'super_admin' | 'user'; groups?: GroupSummary[]; }) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null, userId: null, email: null, name: null, role: null, groups: [],
setAuth: (data) => set({ ...data, groups: data.groups ?? [] }),
logout: () => set({
accessToken: null, userId: null, email: null, name: null, role: null, groups: [],
}),
}),
{ name: 'my-fund-auth' },
),
);
EOF
4.8 Điểm bắt đầu của React¶
Ghi đè file src/main.tsx để gắn TanStack Query vào:
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 30_000 }, // Dữ liệu 30s mới thiu
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);
4.9 Chia đường đi (Routing)¶
Ghi đè file src/App.tsx:
// src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import LoginPage from './pages/LoginPage';
import GroupsListPage from './pages/GroupsListPage';
// Trạm gác cho FrontEnd - Không có token thì xin mời quay về trang đăng nhập
function RequireAuth({ children }: { children: React.ReactNode }) {
const accessToken = useAuthStore((s) => s.accessToken);
if (!accessToken) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
const accessToken = useAuthStore((s) => s.accessToken);
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to={accessToken ? '/groups' : '/login'} replace />} />
<Route path="/login" element={
accessToken ? <Navigate to="/groups" replace /> : <LoginPage />
} />
<Route path="/groups" element={
<RequireAuth><GroupsListPage /></RequireAuth>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
4.10 Tạo Trang Đăng Nhập¶
Tạo thư mục src/pages và tạo file LoginPage.tsx:
// src/pages/LoginPage.tsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';
export default function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const [isSetup, setIsSetup] = useState<boolean | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Xem hệ thống đã có ai khai trương chưa
useEffect(() => {
api.auth.setupStatus().then((r) => setIsSetup(r.isSetup)).catch(() => setIsSetup(true));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = isSetup
? await api.auth.login({ email, password })
: await api.auth.setup({ email, name, password });
setAuth(result);
navigate('/groups', { replace: true });
} catch (err: any) {
setError(err.message ?? 'Lỗi gì đó rồi!');
} finally {
setLoading(false);
}
};
if (isSetup === null) return <div className="p-8">Đang tải, chờ xíu nha...</div>;
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-xl shadow-sm w-full max-w-md space-y-4">
<h1 className="text-2xl font-bold">
{isSetup ? 'Đăng nhập' : 'Tạo tài khoản quản trị viên (Lần đầu)'}
</h1>
{!isSetup && (
<input
type="text" placeholder="Tên gọi cho ngầu" required
value={name} onChange={(e) => setName(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
/>
)}
<input
type="email" placeholder="Email của bạn" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
/>
<input
type="password" placeholder="Mật khẩu (ít nhất 8 ký tự)" required minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
/>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit" disabled={loading}
className="w-full bg-indigo-600 text-white py-2 rounded-lg disabled:opacity-50"
>
{loading ? 'Đang chạy nhé...' : (isSetup ? 'Đăng nhập' : 'Khai trương')}
</button>
</form>
</div>
);
}
4.11 Tạo Trang Danh Sách Quỹ Nhóm¶
Tạo file GroupsListPage.tsx chung thư mục pages:
// src/pages/GroupsListPage.tsx
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';
export default function GroupsListPage() {
const { accessToken, name, logout } = useAuthStore();
const queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState('');
const { data: groups = [], isLoading } = useQuery({
queryKey: ['groups'],
queryFn: () => api.groups.list(accessToken!),
enabled: !!accessToken,
});
const createGroup = useMutation({
mutationFn: (name: string) => api.groups.create({ name }, accessToken!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setNewGroupName('');
},
});
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-4 flex justify-between items-center">
<h1 className="text-lg font-semibold">Chào sếp {name} 👋</h1>
<button onClick={logout} className="text-gray-500 hover:text-red-500">Đăng xuất</button>
</header>
<main className="max-w-3xl mx-auto p-6 space-y-6">
<form
onSubmit={(e) => { e.preventDefault(); if (newGroupName.trim()) createGroup.mutate(newGroupName.trim()); }}
className="bg-white rounded-xl shadow-sm p-4 flex gap-2"
>
<input
value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)}
placeholder="Bạn muốn đặt tên nhóm là gì?"
className="flex-1 border rounded-lg px-3 py-2"
/>
<button
type="submit" disabled={createGroup.isPending}
className="bg-indigo-600 text-white px-4 rounded-lg disabled:opacity-50"
>
Tạo ngay
</button>
</form>
{isLoading && <p>Đang lục tìm dữ liệu...</p>}
<div className="grid gap-3">
{groups.map((g) => (
<div key={g.id} className="bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition">
<h3 className="font-semibold text-lg text-indigo-700">{g.name}</h3>
<p className="text-sm text-gray-500 mt-1">
Tiền tệ: {g.currency} · Chức vụ: <span className="font-medium text-gray-700">{g.member_role}</span>
</p>
</div>
))}
{!isLoading && groups.length === 0 && (
<p className="text-gray-500 text-center py-8">Bạn đang nhẵn túi, ủa nhầm, chưa có nhóm nào. Nhập tên và tạo ngay đi!</p>
)}
</div>
</main>
</div>
);
}
Bước 5 — Chạy Thử Ngay Tắp Lự (Test Local) 🏃♂️¶
Mở 2 cái Terminal lên để chạy song song nhé:
# Terminal 1: Chạy API
cd api && wrangler dev
# Nó sẽ báo là đang chạy ở: http://localhost:8787
# Terminal 2: Chạy Web
cd web && npm run dev
# Nó sẽ chạy ở: http://localhost:5173
Bạn cứ mở http://localhost:5173 lên trình duyệt. Lần đầu nó sẽ bắt bạn nhập thông tin "Khai trương". Bạn tự test thử các nút bấm xem có chạy ngon lành không nhé!
Bước 6 — Cho Cả Thế Giới Thấy (Deploy Production) 🌍¶
6.1 Đẩy API lên mạng¶
cd api
# Đặt chìa khóa bí mật cho môi trường thật (Gõ chữ linh tinh dài dài xíu)
wrangler secret put JWT_SECRET
# Đổ khung bảng vào Database thật trên Cloudflare
wrangler d1 migrations apply my-fund-db
# Phát hỏa! Bắn code lên mạng
wrangler deploy
# → Nhớ copy cái đường link nó thả ra nhé (VD: [https://my-fund-api.xxx.workers.dev](https://my-fund-api.xxx.workers.dev))
6.2 Báo cho API biết địa chỉ nhà Web mới¶
Mở file api/wrangler.toml đổi lại phần [vars]:
Sau đó nhớ chạy lại wrangler deploy để nó ăn cấu hình.
6.3 Đẩy Web lên mạng¶
cd web
# Cho web biết API đang nằm ở phương trời nào
echo "VITE_API_URL=[https://my-fund-api.xxx.workers.dev](https://my-fund-api.xxx.workers.dev)" > .env.production
# Xây gạch (Build) và Đẩy lên Cloudflare Pages
npm run build
wrangler pages deploy dist --project-name my-fund-app
# → Xong! Web của bạn đã có mặt trên đời tại: [https://my-fund-app.pages.dev](https://my-fund-app.pages.dev)
6.4 Nhét biến môi trường lên Cloudflare Dashboard¶
Đừng quên vào trang web quản lý của Cloudflare (Dashboard) → Pages → my-fund-app → Settings → Environment variables. Thêm biến VITE_API_URL bằng cái link API của bạn. Xong ấn nút Redeploy lại là mượt.
Tự Kiểm Tra (Checklist Cuối) ✅¶
- [ ] Chạy
wrangler whoamithấy hiện tên mình. - [ ] Database đã tạo và ID dán đúng chỗ trong
wrangler.toml. - [ ] Khởi tạo bảng bằng lệnh migrations (cả local và production).
- [ ] Đã thêm secret
JWT_SECRET. - [ ] Gõ
curl https://your-api.workers.dev/healthtrên terminal trả về"status":"ok". - [ ] Web chạy mượt mà, F5 (tải lại trang) không bị văng ra khỏi phiên đăng nhập.
Làm Gì Tiếp Theo? 🎯¶
Ngay khi chạy được hệ thống cốt lõi, bạn đã là một lập trình viên Fullstack dấn thân vào con đường Serverless rồi đó! Tiếp theo bạn có thể vọc vạch:
- Gắn khiên chống Spam (Cloudflare Turnstile) ở trang đăng nhập.
- Làm tính năng gửi thiệp mời tham gia nhóm bằng mã Code.
- Tạo ra các mục Thu - Chi thực sự.
Mô típ để làm tính năng mới rất dễ: Thêm cột/bảng trong Database -> Thêm đường dẫn ở API -> Tạo nút bấm ở React. Chúc bạn code vui vẻ nhé!