Connect backend

This commit is contained in:
Thanh Quy - wolf
2026-04-19 17:04:07 +07:00
parent dae381d182
commit 37a6ed58c9
9 changed files with 363 additions and 137 deletions
+30 -6
View File
@@ -13,13 +13,27 @@ const DEMO_OTP = "123456";
export default function RegisterPage() {
const router = useRouter();
const { completeRegistration } = useAuth();
const { completeRegistration, lastError } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({ phone: "", otp: "" });
const mapApiError = (
err: { status: number; body: string } | null,
): string => {
if (!err) return "Đăng ký thất bại. Vui lòng thử lại.";
if (err.status === 0)
return "Không kết nối được đến máy chủ. Kiểm tra backend có đang chạy không.";
if (err.status >= 500)
return `Máy chủ lỗi (${err.status}). Backend có thể chưa sẵn sàng.`;
if (err.body === "ExistedUser") return "Số điện thoại đã được đăng ký";
if (err.body === "InvalidPhoneNumber") return "Số điện thoại không hợp lệ";
return err.body || "Đăng ký thất bại. Vui lòng thử lại.";
};
// Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => {
// Vietnamese phone format: 10 digits starting with 0
@@ -49,7 +63,7 @@ export default function RegisterPage() {
setErrors({ phone: "", otp: "" });
};
const handleOtpSubmit = (e: FormEvent) => {
const handleOtpSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!otp.trim()) {
@@ -62,9 +76,17 @@ export default function RegisterPage() {
return;
}
// Complete registration
completeRegistration(phone);
router.push("/");
setSubmitting(true);
try {
const success = await completeRegistration(phone);
if (success) {
router.push("/");
} else {
setErrors({ ...errors, otp: mapApiError(lastError) });
}
} finally {
setSubmitting(false);
}
};
const handleBackToPhone = () => {
@@ -237,8 +259,9 @@ export default function RegisterPage() {
type="submit"
style="login"
size="lg"
disabled={submitting}
>
Hoàn tất đăng
{submitting ? "Đang đăng ký..." : "Hoàn tất đăng ký"}
</Button>
{/* Back Button */}
@@ -247,6 +270,7 @@ export default function RegisterPage() {
onClick={handleBackToPhone}
size="lg"
style="login"
disabled={submitting}
>
Thay đi số điện thoại
</Button>
+31 -13
View File
@@ -8,10 +8,11 @@ import { FormEvent, useState } from "react";
export default function LoginForm() {
const router = useRouter();
const { login } = useAuth();
const { login, lastError } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({
username: "",
password: "",
@@ -39,21 +40,37 @@ export default function LoginForm() {
return isValid;
};
const handleSubmit = (e: FormEvent) => {
const mapApiError = (
err: { status: number; body: string } | null,
): string => {
if (!err) return "Đăng nhập thất bại. Vui lòng thử lại.";
if (err.status === 0)
return "Không kết nối được đến máy chủ. Kiểm tra backend có đang chạy không.";
if (err.status >= 500)
return `Máy chủ lỗi (${err.status}). Backend có thể chưa sẵn sàng hoặc account-service chưa chạy.`;
if (err.body === "InvalidPhoneNumber") return "Số điện thoại không tồn tại";
if (err.body === "InvalidPassword") return "Mật khẩu không đúng";
return err.body || "Tên đăng nhập hoặc mật khẩu không đúng";
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
const success = login(username, password);
if (success) {
router.push("/");
} else {
setErrors({
username: "",
password: "",
general: "Tên đăng nhập hoặc mật khẩu không đúng",
});
setSubmitting(true);
try {
const success = await login(username, password);
if (success) {
router.push("/");
} else {
setErrors((prev) => ({
...prev,
general: mapApiError(lastError),
}));
}
} finally {
setSubmitting(false);
}
};
@@ -107,8 +124,9 @@ export default function LoginForm() {
type="submit"
style="login"
size="lg"
disabled={submitting}
>
Đăng nhập
{submitting ? "Đang đăng nhập..." : "Đăng nhập"}
</Button>
{/* Register Button */}
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name localhost;
# Điều hướng API tới Backend
location /api {
# 'backend' là tên service được định nghĩa trong docker-compose.yaml
proxy_pass http://host.docker.internal:32080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Điều hướng phần còn lại tới Frontend
location / {
# 'frontend' là tên service được định nghĩa trong docker-compose.yaml
proxy_pass http://host.docker.internal:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+12
View File
@@ -0,0 +1,12 @@
version: "3.8"
services:
nginx:
image: nginx:stable-alpine
container_name: nginx-gateway
ports:
- "80:80"
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
extra_hosts:
- "host.docker.internal:host-gateway"
+53
View File
@@ -0,0 +1,53 @@
import { apiRequest } from "./client";
import type {
ApiCustomer,
LoginPayload,
QuickLoginPayload,
QuickSignupPayload,
SignupPayload,
} from "../types";
const CUSTOMER_BASE = "/api/customer";
export function signup(payload: SignupPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/signup`, {
method: "POST",
body: payload,
});
}
export function quickSignup(payload: QuickSignupPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/quick_signup`, {
method: "POST",
body: payload,
});
}
export function login(payload: LoginPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/login`, {
method: "POST",
body: payload,
});
}
export function quickLogin(payload: QuickLoginPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/quick_login`, {
method: "POST",
body: payload,
});
}
export function me() {
return apiRequest<ApiCustomer>(CUSTOMER_BASE, { method: "GET" });
}
export function logout() {
// Backend has no logout endpoint; clearing the httpOnly `auth` cookie
// requires a server-side Set-Cookie with Max-Age=0. Until the gateway
// exposes /logout, we trigger an invalid-auth response by calling /me
// with a fresh client after clearing browser-accessible state.
// The auth cookie is httpOnly so we cannot remove it from JS; the
// server must do it. For now this is a no-op network call.
return Promise.resolve();
}
+55
View File
@@ -0,0 +1,55 @@
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export class ApiError extends Error {
status: number;
body: string;
constructor(status: number, body: string, message?: string) {
super(message ?? body ?? `Request failed with status ${status}`);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface RequestOptions {
method?: HttpMethod;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
export async function apiRequest<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, headers = {}, signal } = options;
const res = await fetch(`${BASE_URL}${path}`, {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
const rawText = await res.text();
if (!res.ok) {
throw new ApiError(res.status, rawText);
}
if (!rawText) return undefined as T;
try {
return JSON.parse(rawText) as T;
} catch {
return rawText as unknown as T;
}
}
+123 -118
View File
@@ -3,163 +3,168 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import type { User } from "./types";
import * as authApi from "./api/auth";
import { ApiError } from "./api/client";
import type { ApiCustomer, User } from "./types";
export interface AuthErrorInfo {
// HTTP status (0 = network / fetch failure)
status: number;
// Raw body or error message from the backend (e.g. "InvalidPassword")
body: string;
}
interface AuthContextType {
user: User | null;
login: (username: string, password: string) => boolean;
logout: () => void;
loading: boolean;
login: (phone: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
registerPhone: string | null;
setRegisterPhone: (phone: string | null) => void;
completeRegistration: (phone: string) => void;
completeRegistration: (phone: string) => Promise<boolean>;
lastError: AuthErrorInfo | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Mock user database
const MOCK_AUTH_DB = {
// Admin
admin: {
username: "admin",
password: "admin",
user: {
id: 1,
name: "Quản lý",
role: "manager" as const,
avatar: null,
phone: "0912345678",
},
},
const STORAGE_KEY = "coffee-shop-user";
// Staff (username and password are their names)
"Nguyễn Văn An": {
username: "Nguyễn Văn An",
password: "Nguyễn Văn An",
user: {
id: 2,
name: "Nguyễn Văn An",
role: "staff" as const,
avatar: null,
phone: "0901234567",
},
},
"Trần Thị Bình": {
username: "Trần Thị Bình",
password: "Trần Thị Bình",
user: {
id: 3,
name: "Trần Thị Bình",
role: "staff" as const,
avatar: null,
phone: "0902345678",
},
},
"Lê Văn Cường": {
username: "Lê Văn Cường",
password: "Lê Văn Cường",
user: {
id: 4,
name: "Lê Văn Cường",
role: "staff" as const,
avatar: null,
phone: "0903456789",
},
},
// Stable numeric id derived from UUID string — used because local
// User.id is typed as number (staff module uses numeric ids).
// Backend never reads this back; identity on the server side is cookie-based.
function hashUuidToNumber(uuid: string): number {
let h = 0;
for (let i = 0; i < uuid.length; i++) {
h = (h * 31 + uuid.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
// Customers (username is phone number, password is custom)
"0987654321": {
username: "0987654321",
password: "user1",
user: {
id: 5,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0987654321",
},
},
"0976543210": {
username: "0976543210",
password: "user1",
user: {
id: 6,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0976543210",
},
},
};
function toUser(c: ApiCustomer): User {
return {
id: hashUuidToNumber(c.id),
apiId: c.id,
name: c.name,
role: "customer",
avatar: null,
phone: c.phone,
};
}
function toErrorInfo(e: unknown): AuthErrorInfo {
if (e instanceof ApiError) return { status: e.status, body: e.body ?? "" };
if (e instanceof Error) return { status: 0, body: e.message };
return { status: 0, body: String(e) };
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
const [lastError, setLastError] = useState<AuthErrorInfo | null>(null);
// Load user from localStorage on mount
useEffect(() => {
const savedUser = localStorage.getItem("coffee-shop-user");
if (savedUser) {
try {
setUser(JSON.parse(savedUser));
} catch (e) {
console.error("Failed to parse saved user", e);
}
}
const persist = useCallback((u: User | null) => {
if (u) localStorage.setItem(STORAGE_KEY, JSON.stringify(u));
else localStorage.removeItem(STORAGE_KEY);
}, []);
const login = (username: string, password: string): boolean => {
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB];
if (authEntry && authEntry.password === password) {
setUser(authEntry.user);
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user));
return true;
// Hydrate from cached user immediately, then verify against server.
useEffect(() => {
const cached = localStorage.getItem(STORAGE_KEY);
if (cached) {
try {
setUser(JSON.parse(cached));
} catch {
localStorage.removeItem(STORAGE_KEY);
}
}
return false;
};
let cancelled = false;
(async () => {
try {
const current = await authApi.me();
if (!cancelled) {
const u = toUser(current);
setUser(u);
persist(u);
}
} catch {
if (!cancelled) {
setUser(null);
persist(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
const logout = () => {
setUser(null);
localStorage.removeItem("coffee-shop-user");
};
const completeRegistration = (phone: string) => {
// Create new customer account
const newUser: User = {
id: Date.now(),
name: "Khách hàng",
role: "customer",
avatar: null,
phone,
return () => {
cancelled = true;
};
}, [persist]);
// Add to mock database (in real app, this would be API call)
(MOCK_AUTH_DB as any)[phone] = {
username: phone,
password: "user1", // Default password for new customers
user: newUser,
};
const login = useCallback(
async (phone: string, password: string): Promise<boolean> => {
setLastError(null);
try {
const customer = await authApi.login({ phone, password });
const u = toUser(customer);
setUser(u);
persist(u);
return true;
} catch (e) {
setLastError(toErrorInfo(e));
return false;
}
},
[persist],
);
setUser(newUser);
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser));
setRegisterPhone(null);
};
const logout = useCallback(async () => {
try {
await authApi.logout();
} finally {
setUser(null);
persist(null);
}
}, [persist]);
const completeRegistration = useCallback(
async (phone: string): Promise<boolean> => {
setLastError(null);
try {
const customer = await authApi.quickSignup({ phone });
const u = toUser(customer);
setUser(u);
persist(u);
setRegisterPhone(null);
return true;
} catch (e) {
setLastError(toErrorInfo(e));
return false;
}
},
[persist],
);
return (
<AuthContext.Provider
value={{
user,
loading,
login,
logout,
registerPhone,
setRegisterPhone,
completeRegistration,
lastError,
}}
>
{children}
+28
View File
@@ -7,6 +7,34 @@ export interface User {
role: UserRole;
avatar: string | null;
phone?: string;
apiId?: string; // backend UUID — set when user was authenticated via API
}
// ===== AUTH API PAYLOADS (aligned with backend DTOs) =====
export interface SignupPayload {
name: string;
phone: string;
password: string;
}
export interface QuickSignupPayload {
phone: string;
}
export interface LoginPayload {
phone: string;
password: string;
}
export interface QuickLoginPayload {
phone: string;
otp: string;
}
export interface ApiCustomer {
id: string;
name: string;
phone: string;
}
// ===== MENU TYPES =====
+10
View File
@@ -19,6 +19,16 @@ const nextConfig: NextConfig = {
},
],
},
async rewrites() {
const gateway =
process.env.GATEWAY_INTERNAL_URL ?? "http://localhost:3000";
return [
{
source: "/api/:path*",
destination: `${gateway}/api/:path*`,
},
];
},
};
export default nextConfig;