Commit c54b1110 authored by ThinhNC's avatar ThinhNC

feat(ui): polish header controls, standardize page titles, and fix auth form UX

parent 859f6c2c
...@@ -92,3 +92,40 @@ Tất cả các trang, component, thông báo và form trong dự án từ nay v ...@@ -92,3 +92,40 @@ Tất cả các trang, component, thông báo và form trong dự án từ nay v
- Không hardcode chuỗi text trực tiếp vào JSX; luôn sử dụng từ điển ngôn ngữ và hook `useLanguage` / `useTranslation`. - Không hardcode chuỗi text trực tiếp vào JSX; luôn sử dụng từ điển ngôn ngữ và hook `useLanguage` / `useTranslation`.
- Lưu trữ lựa chọn ngôn ngữ và theme trong `localStorage` để ghi nhớ cho các phiên tiếp theo. - Lưu trữ lựa chọn ngôn ngữ và theme trong `localStorage` để ghi nhớ cho các phiên tiếp theo.
- **Vị trí Controls**: Thanh Navbar/Header luôn phải tích hợp `ThemeToggle``LanguageToggle` trực quan, dễ thao tác. - **Vị trí Controls**: Thanh Navbar/Header luôn phải tích hợp `ThemeToggle``LanguageToggle` trực quan, dễ thao tác.
## 8. Quy Chuẩn Đặt Title & Metadata Cho Từng Trang (Page Titles & SEO) - BẮT BUỘC
Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC PHẢI CÓ TIÊU ĐỀ (PAGE TITLE) VÀ METADATA ĐẦY ĐỦ**:
- **Title Template cấu hình tại `src/app/layout.tsx`**:
```tsx
export const metadata: Metadata = {
title: {
default: "Bảng Điều Khiển | Data Crawler System",
template: "%s | Data Crawler",
},
description: "Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime...",
};
```
- **Quy tắc khi tạo Trang Mới**:
- Nếu `page.tsx`**Client Component** (`"use client"`): Vì Next.js App Router cấm export `metadata` từ Client Component, luôn tạo thêm file `layout.tsx` (Server Component) trong cùng thư mục route để export `metadata` và pass through `children`:
```tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Tên Trang",
description: "Mô tả ngắn gọn về chức năng của trang",
};
export default function PageLayout({ children }: { children: React.ReactNode }) {
return children;
}
```
- Nếu `page.tsx`**Server Component**: Export trực tiếp `export const metadata: Metadata = { title: "Tên Trang", description: "..." }`.
- **Danh sách Title chuẩn các trang hiện tại**:
- `/` (Dashboard / Trang chủ): `Bảng Điều Khiển | Data Crawler System`
- `/login`: `Đăng Nhập | Data Crawler`
- `/register`: `Đăng Ký Tài Khoản | Data Crawler`
- `/forgot-password`: `Quên Mật Khẩu | Data Crawler`
- `/reset-password`: `Đặt Lại Mật Khẩu | Data Crawler`
- `/verify-email`: `Xác Thực Email | Data Crawler`
- `/forbidden`: `Truy Cập Bị Từ Chối (403) | Data Crawler`
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quên Mật Khẩu",
description: "Khôi phục mật khẩu tài khoản hệ thống Data Crawler qua email xác nhận",
};
export default function ForgotPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
...@@ -10,7 +10,6 @@ import { ...@@ -10,7 +10,6 @@ import {
Loader2, Loader2,
ArrowLeft, ArrowLeft,
CheckCircle2, CheckCircle2,
Send,
RotateCcw, RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { forgotPasswordSchema, ForgotPasswordInput } from "@/schemas/auth.schema"; import { forgotPasswordSchema, ForgotPasswordInput } from "@/schemas/auth.schema";
...@@ -124,7 +123,6 @@ export default function ForgotPasswordPage() { ...@@ -124,7 +123,6 @@ export default function ForgotPasswordPage() {
onClick={handleResend} onClick={handleResend}
className="w-full rounded-2xl border-border cursor-pointer hover:bg-muted text-xs" className="w-full rounded-2xl border-border cursor-pointer hover:bg-muted text-xs"
> >
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{countdown > 0 {countdown > 0
? `Gửi lại sau ${countdown}s` ? `Gửi lại sau ${countdown}s`
: t.auth.forgot.resendBtn} : t.auth.forgot.resendBtn}
...@@ -134,7 +132,6 @@ export default function ForgotPasswordPage() { ...@@ -134,7 +132,6 @@ export default function ForgotPasswordPage() {
variant="ghost" variant="ghost"
className="w-full rounded-2xl text-xs text-muted-foreground hover:text-foreground cursor-pointer" className="w-full rounded-2xl text-xs text-muted-foreground hover:text-foreground cursor-pointer"
> >
<ArrowLeft className="mr-2 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin} {t.auth.forgot.backToLogin}
</Button> </Button>
</Link> </Link>
...@@ -148,7 +145,7 @@ export default function ForgotPasswordPage() { ...@@ -148,7 +145,7 @@ export default function ForgotPasswordPage() {
{t.auth.forgot.email} {t.auth.forgot.email}
</Label> </Label>
<div className="relative"> <div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Mail className="pointer-events-none absolute left-3.5 top-[calc(50%+1px)] h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="email" id="email"
type="email" type="email"
...@@ -176,10 +173,7 @@ export default function ForgotPasswordPage() { ...@@ -176,10 +173,7 @@ export default function ForgotPasswordPage() {
{t.auth.forgot.submitting} {t.auth.forgot.submitting}
</> </>
) : ( ) : (
<> t.auth.forgot.submit
<Send className="mr-2 h-4 w-4" />
{t.auth.forgot.submit}
</>
)} )}
</Button> </Button>
...@@ -188,7 +182,6 @@ export default function ForgotPasswordPage() { ...@@ -188,7 +182,6 @@ export default function ForgotPasswordPage() {
href="/login" href="/login"
className="inline-flex items-center text-xs font-medium text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
> >
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
{t.auth.forgot.backToLogin} {t.auth.forgot.backToLogin}
</Link> </Link>
</div> </div>
......
...@@ -16,7 +16,7 @@ export default function AuthLayout({ ...@@ -16,7 +16,7 @@ export default function AuthLayout({
<div className="pointer-events-none absolute -bottom-24 right-1/4 -z-10 h-96 w-96 rounded-full bg-teal-500/10 blur-3xl dark:bg-teal-500/10" /> <div className="pointer-events-none absolute -bottom-24 right-1/4 -z-10 h-96 w-96 rounded-full bg-teal-500/10 blur-3xl dark:bg-teal-500/10" />
{/* Auth Minimal Header */} {/* Auth Minimal Header */}
<header className="mx-auto flex w-full max-w-7xl items-center justify-between"> <header className="flex w-full items-center justify-between">
<Link <Link
href="/" href="/"
className="flex items-center gap-2.5 transition-opacity hover:opacity-90" className="flex items-center gap-2.5 transition-opacity hover:opacity-90"
...@@ -25,10 +25,7 @@ export default function AuthLayout({ ...@@ -25,10 +25,7 @@ export default function AuthLayout({
<Bot className="h-5 w-5" /> <Bot className="h-5 w-5" />
</div> </div>
<span className="font-bold text-base tracking-tight text-foreground"> <span className="font-bold text-base tracking-tight text-foreground">
DataCrawler{" "} DataCrawler
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
v1.0
</span>
</span> </span>
</Link> </Link>
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đăng Nhập",
description: "Đăng nhập vào hệ thống quản trị và giám sát dữ liệu Data Crawler",
};
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
...@@ -86,7 +86,7 @@ function LoginForm() { ...@@ -86,7 +86,7 @@ function LoginForm() {
{t.auth.login.email} {t.auth.login.email}
</Label> </Label>
<div className="relative"> <div className="relative">
<Mail className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Mail className="pointer-events-none absolute left-3.5 top-[calc(50%+1px)] h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
id="email" id="email"
type="email" type="email"
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đăng Ký Tài Khoản",
description: "Đăng ký tài khoản mới trên hệ thống thu thập và phân tích dữ liệu Data Crawler",
};
export default function RegisterLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Đặt Lại Mật Khẩu",
description: "Thiết lập mật khẩu bảo mật mới cho tài khoản hệ thống Data Crawler",
};
export default function ResetPasswordLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
...@@ -14,7 +14,6 @@ import { ...@@ -14,7 +14,6 @@ import {
CheckCircle2, CheckCircle2,
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { resetPasswordSchema, ResetPasswordInput } from "@/schemas/auth.schema"; import { resetPasswordSchema, ResetPasswordInput } from "@/schemas/auth.schema";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
...@@ -235,10 +234,7 @@ function ResetPasswordForm() { ...@@ -235,10 +234,7 @@ function ResetPasswordForm() {
{t.auth.reset.submitting} {t.auth.reset.submitting}
</> </>
) : ( ) : (
<> t.auth.reset.submit
<ShieldCheck className="mr-2 h-4 w-4" />
{t.auth.reset.submit}
</>
)} )}
</Button> </Button>
</form> </form>
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Xác Thực Email",
description: "Xác thực và kích hoạt tài khoản người dùng hệ thống Data Crawler",
};
export default function VerifyEmailLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Truy Cập Bị Từ Chối (403)",
description: "Bạn không có quyền truy cập vào khu vực này trong hệ thống Data Crawler",
};
export default function ForbiddenLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
...@@ -15,7 +15,10 @@ const geistMono = Geist_Mono({ ...@@ -15,7 +15,10 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Data Crawler System | Nền Tảng Thu Thập Dữ Liệu Tự Động", title: {
default: "Bảng Điều Khiển | Data Crawler System",
template: "%s | Data Crawler",
},
description: description:
"Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime, tích hợp Next.js, TanStack Query, React Hook Form và Tailwind CSS.", "Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime, tích hợp Next.js, TanStack Query, React Hook Form và Tailwind CSS.",
}; };
......
...@@ -15,14 +15,11 @@ export function LanguageToggle() { ...@@ -15,14 +15,11 @@ export function LanguageToggle() {
<button <button
type="button" type="button"
onClick={toggleLanguage} onClick={toggleLanguage}
title={`${t.lang.toggleTitle}: ${locale === "vi" ? "Chuyển sang English" : "Switch to Tiếng Việt"}`} title={`${t.lang.toggleTitle}: ${locale === "vi" ? "Switch to English" : "Chuyển sang Tiếng Việt"}`}
className="inline-flex items-center gap-1.5 rounded-2xl border border-emerald-500/20 bg-emerald-950/10 px-3 py-1.5 text-xs font-semibold text-slate-700 transition-all hover:border-emerald-500/40 hover:bg-emerald-500/10 cursor-pointer dark:bg-emerald-950/30 dark:text-slate-200 dark:hover:bg-emerald-950/50" className="inline-flex h-8 items-center gap-1.5 rounded-xl border border-border bg-card/60 px-2.5 text-xs font-semibold text-foreground transition-all hover:bg-muted cursor-pointer shadow-sm"
> >
<Languages className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" /> <Languages className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
<span className="font-mono tracking-wider">{locale === "vi" ? "VI" : "EN"}</span> <span className="font-mono tracking-wide font-bold">{locale === "vi" ? "VN" : "EN"}</span>
<span className="text-[10px] text-slate-400 dark:text-slate-500">
/ {locale === "vi" ? "EN" : "VI"}
</span>
</button> </button>
); );
} }
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react"; import { Activity, Bot, Database, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle"; import { LanguageToggle } from "./language-toggle";
import { UserMenu } from "./user-menu"; import { UserMenu } from "./user-menu";
...@@ -13,21 +13,13 @@ export function Navbar() { ...@@ -13,21 +13,13 @@ export function Navbar() {
return ( return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200"> <header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6"> <div className="flex h-16 w-full items-center justify-between px-4 sm:px-6 lg:px-8">
{/* Brand */} {/* Brand */}
<Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90"> <Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-700 shadow-md shadow-emerald-500/20">
<Bot className="h-6 w-6 text-white" /> <Bot className="h-6 w-6 text-white" />
</div> </div>
<div> <span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
<div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-foreground">DataCrawler</span>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
v1.0
</span>
</div>
<p className="text-xs text-muted-foreground hidden sm:block">{t.nav.brandSub}</p>
</div>
</Link> </Link>
{/* Center Nav Links */} {/* Center Nav Links */}
...@@ -55,10 +47,10 @@ export function Navbar() { ...@@ -55,10 +47,10 @@ export function Navbar() {
</Link> </Link>
</nav> </nav>
{/* Right Controls: Theme Toggle, Language Toggle, Status, Reload & User Menu */} {/* Right Controls: Theme Toggle, Language Toggle, Status & User Menu */}
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
{/* Active Status Badge */} {/* Active Status Badge */}
<div className="hidden lg:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-300"> <div className="hidden sm:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-300">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span> <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
...@@ -72,15 +64,6 @@ export function Navbar() { ...@@ -72,15 +64,6 @@ export function Navbar() {
{/* Theme Switcher */} {/* Theme Switcher */}
<ThemeToggle /> <ThemeToggle />
{/* Reload Page */}
<button
onClick={() => window.location.reload()}
title={t.nav.reload}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-border bg-card/60 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
{/* User Profile & Auth Menu */} {/* User Profile & Auth Menu */}
<UserMenu /> <UserMenu />
</div> </div>
......
"use client"; "use client";
import React, { useSyncExternalStore } from "react"; import React, { useState, useRef, useEffect, useSyncExternalStore } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Monitor, Moon, Sun } from "lucide-react"; import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
const emptySubscribe = () => () => {}; const emptySubscribe = () => () => {};
...@@ -10,61 +10,122 @@ const emptySubscribe = () => () => {}; ...@@ -10,61 +10,122 @@ const emptySubscribe = () => () => {};
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useLanguage(); const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false); const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
// Close dropdown on click outside or Escape key
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
if (!mounted) { if (!mounted) {
return ( return (
<div className="flex h-9 w-[104px] items-center rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-1 opacity-60" /> <div className="h-8 w-8 rounded-xl border border-border/60 bg-card/60 animate-pulse" />
); );
} }
return ( const themeOptions = [
<div {
className="inline-flex items-center rounded-2xl border border-emerald-500/20 bg-emerald-950/10 p-1 backdrop-blur-sm dark:bg-emerald-950/30" key: "light",
role="group" label: t.theme.light,
aria-label={t.theme.toggleTitle} icon: Sun,
> iconColor: "text-amber-500",
<button },
type="button" {
onClick={() => setTheme("light")} key: "dark",
title={`${t.theme.light} (${t.theme.toggleTitle})`} label: t.theme.dark,
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${ icon: Moon,
theme === "light" iconColor: "text-emerald-500",
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30" },
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300" {
}`} key: "system",
> label: t.theme.system,
<Sun className="h-4 w-4" /> icon: Monitor,
<span className="sr-only">{t.theme.light}</span> iconColor: "text-emerald-600 dark:text-emerald-400",
</button> },
] as const;
<button const currentOption =
type="button" themeOptions.find((opt) => opt.key === theme) || themeOptions[2];
onClick={() => setTheme("dark")} const CurrentIcon = currentOption.icon;
title={`${t.theme.dark} (${t.theme.toggleTitle})`}
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${
theme === "dark"
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30"
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300"
}`}
>
<Moon className="h-4 w-4" />
<span className="sr-only">{t.theme.dark}</span>
</button>
return (
<div ref={menuRef} className="relative inline-block text-left">
<button <button
type="button" type="button"
onClick={() => setTheme("system")} onClick={() => setIsOpen((prev) => !prev)}
title={`${t.theme.system} (${t.theme.toggleTitle})`} aria-haspopup="true"
className={`flex h-7 w-7 items-center justify-center rounded-xl transition-all cursor-pointer ${ aria-expanded={isOpen}
theme === "system" title={t.theme.toggleTitle}
? "bg-emerald-500 text-white shadow-sm shadow-emerald-600/30" className={`flex h-8 w-8 items-center justify-center rounded-xl border transition-all cursor-pointer shadow-sm ${
: "text-slate-600 hover:text-emerald-700 dark:text-slate-400 dark:hover:text-emerald-300" isOpen
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border bg-card/60 text-muted-foreground hover:bg-muted hover:text-foreground"
}`} }`}
> >
<Monitor className="h-4 w-4" /> <CurrentIcon className={`h-4 w-4 ${currentOption.iconColor}`} />
<span className="sr-only">{t.theme.system}</span> <span className="sr-only">{t.theme.toggleTitle}</span>
</button> </button>
{isOpen && (
<div
role="menu"
aria-orientation="vertical"
className="absolute right-0 mt-2 w-36 origin-top-right rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150"
>
<div className="space-y-0.5">
{themeOptions.map((opt) => {
const Icon = opt.icon;
const isSelected = theme === opt.key;
return (
<button
key={opt.key}
role="menuitem"
type="button"
onClick={() => {
setTheme(opt.key);
setIsOpen(false);
}}
className={`flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted hover:text-foreground border border-transparent"
}`}
>
<div className="flex items-center gap-2">
<Icon className={`h-3.5 w-3.5 ${opt.iconColor}`} />
<span>{opt.label}</span>
</div>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
)}
</button>
);
})}
</div>
</div>
)}
</div> </div>
); );
} }
...@@ -137,7 +137,7 @@ export const translations = { ...@@ -137,7 +137,7 @@ export const translations = {
submitting: "Đang gửi...", submitting: "Đang gửi...",
backToLogin: "Quay lại Đăng nhập", backToLogin: "Quay lại Đăng nhập",
successTitle: "Đã gửi liên kết khôi phục!", successTitle: "Đã gửi liên kết khôi phục!",
successDesc: "Nếu email tồn tại trong hệ thống, bạn sẽ nhận được đường dẫn đặt lại mật khẩu trong ít phút.", successDesc: "Đường dẫn đặt lại mật khẩu đã được gửi đến email của bạn. Vui lòng kiểm tra hộp thư.",
resendBtn: "Gửi lại email", resendBtn: "Gửi lại email",
}, },
reset: { reset: {
...@@ -323,7 +323,7 @@ export const translations = { ...@@ -323,7 +323,7 @@ export const translations = {
submitting: "Sending...", submitting: "Sending...",
backToLogin: "Back to Sign In", backToLogin: "Back to Sign In",
successTitle: "Reset Link Sent!", successTitle: "Reset Link Sent!",
successDesc: "If the email exists in our system, you will receive a password reset link shortly.", successDesc: "A password reset link has been sent to your email address. Please check your inbox.",
resendBtn: "Resend email", resendBtn: "Resend email",
}, },
reset: { reset: {
......
...@@ -14,20 +14,27 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined ...@@ -14,20 +14,27 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
const LANGUAGE_STORAGE_KEY = "data_crawler_lang"; const LANGUAGE_STORAGE_KEY = "data_crawler_lang";
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(() => { // Always initialize with "vi" to match SSR output and prevent hydration mismatch
if (typeof window !== "undefined") { const [locale, setLocaleState] = useState<Locale>("vi");
React.useEffect(() => {
try {
const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null; const saved = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Locale | null;
if (saved && (saved === "vi" || saved === "en")) { if (saved && (saved === "vi" || saved === "en")) {
return saved; setLocaleState(saved);
document.documentElement.lang = saved;
return;
} }
const browserLang = navigator.language?.toLowerCase() || ""; const browserLang = navigator.language?.toLowerCase() || "";
if (browserLang.startsWith("vi")) { const initial = browserLang.startsWith("vi") ? "vi" : "en";
return "vi"; if (initial !== "vi") {
setLocaleState(initial);
document.documentElement.lang = initial;
} }
return "en"; } catch {
// Ignore errors if localStorage is restricted
} }
return "vi"; }, []);
});
const setLocale = (newLocale: Locale) => { const setLocale = (newLocale: Locale) => {
setLocaleState(newLocale); setLocaleState(newLocale);
......
...@@ -3,6 +3,24 @@ ...@@ -3,6 +3,24 @@
import * as React from "react"; import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
// Suppress false-positive React 19 dev warning caused by next-themes inline ThemeScript
if (process.env.NODE_ENV === "development") {
const globalObj = (typeof window !== "undefined" ? window : globalThis) as unknown as {
__suppressed_react19_script_warning?: boolean;
};
if (!globalObj.__suppressed_react19_script_warning) {
globalObj.__suppressed_react19_script_warning = true;
const origError = console.error;
console.error = (...args: unknown[]) => {
const msg = typeof args[0] === "string" ? args[0] : "";
if (msg.includes("Encountered a script tag")) {
return;
}
origError.apply(console, args);
};
}
}
export function ThemeProvider({ export function ThemeProvider({
children, children,
...props ...props
......
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