From 5cafbaec098113ce89284fe4982d34e0d020acb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A2m=20T=C3=A0i=20L=E1=BB=A3i?= <143779007+lamtailoi2@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:37:30 +0700 Subject: [PATCH 1/5] [LoiLT] feat: add github workflow --- .github/workflows/main.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..727b98b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,21 @@ +name: Vercel Production Deployment +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} +on: + push: + branches: + - main +jobs: + Deploy-Production: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install Vercel CLI + run: npm install --global vercel@latest + - name: Pull Vercel Environment Information + run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} + - name: Build Project Artifacts + run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy Project Artifacts to Vercel + run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} From fb855cc7f9ad310e4178ad02122c4bae63d24891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A2m=20T=C3=A0i=20L=E1=BB=A3i?= <143779007+lamtailoi2@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:49:37 +0700 Subject: [PATCH 2/5] Add environment variables for VITE_API_URL and STRIPE_PUBLIC_KEY --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 727b98b..d6f4149 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,8 @@ name: Vercel Production Deployment env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VITE_API_URL: ${{ secrets.VITE_API_URL }} + STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_PUBLIC_KEY }} on: push: branches: From 12ff16ebea6708f2896efd9c18674242b650a780 Mon Sep 17 00:00:00 2001 From: lamtailoi2 Date: Sat, 4 Apr 2026 23:23:35 +0700 Subject: [PATCH 3/5] feat: add vercel.json --- vercel.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..29c0269 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} \ No newline at end of file From 70c0665336153f320e9826e8a73ee4b39a4a04e7 Mon Sep 17 00:00:00 2001 From: lamtailoi2 Date: Sun, 5 Apr 2026 15:38:20 +0700 Subject: [PATCH 4/5] feat: implement i18n --- .gitignore | 2 + package-lock.json | 105 ++++++- package.json | 3 + src/components/DynamicBreadcrumb.tsx | 7 +- src/components/layout/LanguageSwitcher.tsx | 40 +++ src/components/side-bar/AppSideBar.tsx | 16 +- src/components/side-bar/sidebarItems.ts | 55 ++-- src/i18n/config.ts | 27 ++ src/index.css | 46 ++-- src/layout/index.tsx | 8 +- src/locales/en/translation.json | 256 ++++++++++++++++++ src/locales/vi/translation.json | 256 ++++++++++++++++++ src/main.tsx | 1 + src/pages/auth/components/LoginForm.tsx | 16 +- src/pages/auth/components/RegisterForm.tsx | 34 +-- .../auth/components/VerificationAlert.tsx | 10 +- src/pages/auth/login/index.tsx | 10 +- src/pages/auth/register/index.tsx | 13 +- .../dashboard/components/AdminDashboard.tsx | 4 +- .../components/CustomerDashboard.tsx | 7 +- .../dashboard/components/StaffDashboard.tsx | 7 +- .../components/TechnicianDashboard.tsx | 8 +- .../components/card/InventoryStatusCard.tsx | 12 +- .../components/card/OverviewCard.tsx | 76 +++--- .../components/card/OverviewCardCustomer.tsx | 12 +- .../components/card/OverviewCardSpending.tsx | 14 +- .../card/TechnicianWorkSchedule.tsx | 29 +- .../components/card/TrendingCard.tsx | 104 +++---- .../components/card/WorkScheduleCard.tsx | 26 +- .../components/chart/CenterBarChart.tsx | 12 +- .../components/chart/RevenueChart.tsx | 42 +-- .../components/table/RecentBookingTable.tsx | 6 +- .../dashboard/components/table/columns.tsx | 33 +-- .../inventory/AdminInventoryManagement.tsx | 4 +- .../TechnicianInventoryManagement.tsx | 4 +- .../inventory/components/ItemsListSection.tsx | 12 +- .../components/StatisticsSection.tsx | 18 +- .../inventory/components/table/columns.tsx | 29 +- .../technician/table/InventoryTable.tsx | 12 +- .../components/technician/table/columns.tsx | 29 +- src/pages/notfound/index.tsx | 14 +- .../components/NotificationPopover.tsx | 28 +- .../profile/components/ChangePasswordForm.tsx | 18 +- .../components/profile/DetailedSettingBox.tsx | 5 +- .../components/profile/GeneralInfoBox.tsx | 16 +- .../profile/components/profile/Profile.tsx | 4 +- .../components/profile/ProfileForm.tsx | 22 +- src/pages/unauthorized/index.tsx | 5 +- 48 files changed, 1149 insertions(+), 368 deletions(-) create mode 100644 src/components/layout/LanguageSwitcher.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/locales/en/translation.json create mode 100644 src/locales/vi/translation.json diff --git a/.gitignore b/.gitignore index 8a62cd7..2a52e1a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ dist-ssr #env .env .vercel + +.agent diff --git a/package-lock.json b/package-lock.json index 0b0187a..1bb386c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,8 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.13", "framer-motion": "^12.23.22", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.541.0", "next-themes": "^0.4.6", "react": "^19.1.1", @@ -50,6 +52,7 @@ "react-day-picker": "^9.11.1", "react-dom": "^19.1.1", "react-hook-form": "^7.65.0", + "react-i18next": "^17.0.2", "react-icons": "^5.5.0", "react-router-dom": "^7.8.2", "react-slick": "^0.31.0", @@ -314,9 +317,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4723,6 +4726,55 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5721,6 +5773,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -6387,7 +6466,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6527,6 +6606,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -6652,6 +6740,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/package.json b/package.json index 00373bf..5d695a0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.13", "framer-motion": "^12.23.22", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.541.0", "next-themes": "^0.4.6", "react": "^19.1.1", @@ -52,6 +54,7 @@ "react-day-picker": "^9.11.1", "react-dom": "^19.1.1", "react-hook-form": "^7.65.0", + "react-i18next": "^17.0.2", "react-icons": "^5.5.0", "react-router-dom": "^7.8.2", "react-slick": "^0.31.0", diff --git a/src/components/DynamicBreadcrumb.tsx b/src/components/DynamicBreadcrumb.tsx index 2437636..e52dadf 100644 --- a/src/components/DynamicBreadcrumb.tsx +++ b/src/components/DynamicBreadcrumb.tsx @@ -21,11 +21,14 @@ function formatPath(path: string) { .replace(/\b\w/g, (c) => c.toUpperCase()); } +import { useTranslation } from "react-i18next"; + export default function DynamicBreadcrumbs({ pathTitles, hasPage = true, ignorePaths = [], }: Props) { + const { t } = useTranslation(); const location = useLocation(); const paths = location.pathname .split("/") @@ -48,7 +51,7 @@ export default function DynamicBreadcrumbs({ {isLast || !hasPage ? ( - {title} + {t(title)} ) : ( @@ -56,7 +59,7 @@ export default function DynamicBreadcrumbs({ to={href} className="font-medium text-xs ml-4 lg:text-2xl md:text-sm font-inter" > - {title} + {t(title)} )} diff --git a/src/components/layout/LanguageSwitcher.tsx b/src/components/layout/LanguageSwitcher.tsx new file mode 100644 index 0000000..efbd594 --- /dev/null +++ b/src/components/layout/LanguageSwitcher.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Globe } from "lucide-react"; + +export function LanguageSwitcher() { + const { i18n } = useTranslation(); + + const handleLanguageChange = (value: string) => { + i18n.changeLanguage(value); + }; + + return ( + + ); +} diff --git a/src/components/side-bar/AppSideBar.tsx b/src/components/side-bar/AppSideBar.tsx index 0a58d87..fae213b 100644 --- a/src/components/side-bar/AppSideBar.tsx +++ b/src/components/side-bar/AppSideBar.tsx @@ -34,6 +34,7 @@ import { technicianItems, staffItems, } from "./sidebarItems"; +import { useTranslation } from "react-i18next"; const getMenuItems = (role: AccountRole) => { switch (role) { @@ -52,6 +53,7 @@ const getMenuItems = (role: AccountRole) => { // ----------------- SIDEBAR COMPONENT ----------------- export function AppSidebar() { + const { t } = useTranslation(); const { auth } = useAuth(); const role = auth.user?.role; const { resolvedTheme } = useTheme(); @@ -138,7 +140,7 @@ export function AppSidebar() { {effectiveCollapsed ? (
@@ -147,7 +149,7 @@ export function AppSidebar() { ) : ( <> - {item.title} + {t(item.title)} )} @@ -172,7 +174,7 @@ export function AppSidebar() { > - {child.title} + {t(child.title)} ))} @@ -197,7 +199,7 @@ export function AppSidebar() { > {effectiveCollapsed ? ( - +
@@ -205,7 +207,7 @@ export function AppSidebar() { ) : ( <> - {item.title} + {t(item.title)} )}
@@ -235,7 +237,7 @@ export function AppSidebar() { {({ isActive }) => (
{effectiveCollapsed ? ( - + @@ -251,7 +253,7 @@ export function AppSidebar() { isActive && "dark:text-amber-primary", )} > - {auth.user?.role === AccountRole.ADMIN && "Admin"} + {auth.user?.role === AccountRole.ADMIN && t("sidebar.role.admin")} {auth.user?.role !== AccountRole.ADMIN && auth.user?.profile?.firstName + " " + diff --git a/src/components/side-bar/sidebarItems.ts b/src/components/side-bar/sidebarItems.ts index 4fb5d7e..f496c42 100644 --- a/src/components/side-bar/sidebarItems.ts +++ b/src/components/side-bar/sidebarItems.ts @@ -17,52 +17,53 @@ import { } from "lucide-react"; export const adminItems: SidebarItem[] = [ - { title: "Dashboard", url: "/dashboard", icon: Home }, - { title: "Customers & Vehicles", url: "/vehicles", icon: Car }, + { title: "sidebar.titles.dashboard", url: "/dashboard", icon: Home }, + { title: "sidebar.titles.vehicles", url: "/vehicles", icon: Car }, { - title: "Employee Management", + title: "sidebar.titles.employees", icon: IdCardLanyard, children: [ - { title: "Staffs", url: "/employees/staffs", icon: Users }, + { title: "sidebar.titles.staffs", url: "/employees/staffs", icon: Users }, { - title: "Technicians", + title: "sidebar.titles.technicians", url: "/employees/technicians", icon: UserRoundCog, }, ], }, { - title: "Work Shifts Management", + title: "sidebar.titles.shifts", icon: CalendarClock, url: "/shifts", }, - { title: "Inventory", url: "/inventory", icon: PackageOpen }, - { title: "Memberships", url: "/membership", icon: UserStar }, - { title: "Notifications", url: "/notification", icon: Bell }, + { title: "sidebar.titles.inventory", url: "/inventory", icon: PackageOpen }, + { title: "sidebar.titles.memberships", url: "/membership", icon: UserStar }, + { title: "sidebar.titles.notifications", url: "/notification", icon: Bell }, ]; export const customerItems: SidebarItem[] = [ - { title: "Dashboard", url: "/dashboard", icon: Home }, - { title: "My Vehicles", url: "/vehicles", icon: Car }, - { title: "Memberships", url: "/membership", icon: UserStar }, - { title: "Service Booking", url: "/booking", icon: NotebookPen }, - { title: "Chat Box", url: "/chat", icon: MessageCircle }, - { title: "Notifications", url: "/notification", icon: Bell }, - { title: "Help & Support", url: "/support", icon: CircleQuestionMark }, + { title: "sidebar.titles.dashboard", url: "/dashboard", icon: Home }, + { title: "sidebar.titles.my_vehicles", url: "/vehicles", icon: Car }, + { title: "sidebar.titles.memberships", url: "/membership", icon: UserStar }, + { title: "sidebar.titles.booking", url: "/booking", icon: NotebookPen }, + { title: "sidebar.titles.chat", url: "/chat", icon: MessageCircle }, + { title: "sidebar.titles.notifications", url: "/notification", icon: Bell }, + { title: "sidebar.titles.support", url: "/support", icon: CircleQuestionMark }, ]; export const staffItems: SidebarItem[] = [ - { title: "Dashboard", url: "/dashboard", icon: Home }, - { title: "Work Schedule", url: "/viewSchedule", icon: CalendarRange }, - { title: "Booking Management", url: "/booking", icon: BookOpenCheckIcon }, - { title: "Customers & Vehicles", url: "/vehicles", icon: Users }, - { title: "Chat Box", url: "/chat", icon: MessageCircle }, - { title: "Notifications", url: "/notification", icon: Bell }, + { title: "sidebar.titles.dashboard", url: "/dashboard", icon: Home }, + { title: "sidebar.titles.schedule", url: "/viewSchedule", icon: CalendarRange }, + { title: "sidebar.titles.booking_management", url: "/booking", icon: BookOpenCheckIcon }, + { title: "sidebar.titles.vehicles", url: "/vehicles", icon: Users }, + { title: "sidebar.titles.chat", url: "/chat", icon: MessageCircle }, + { title: "sidebar.titles.notifications", url: "/notification", icon: Bell }, ]; + export const technicianItems: SidebarItem[] = [ - { title: "Dashboard", url: "/dashboard", icon: Home }, - { title: "Work Schedule", url: "/viewSchedule", icon: CalendarRange }, - { title: "My Assigned Bookings", url: "/booking", icon: CalendarClock }, - { title: "Inventory", url: "/inventory", icon: PackageOpen }, - { title: "Notifications", url: "/notification", icon: Bell }, + { title: "sidebar.titles.dashboard", url: "/dashboard", icon: Home }, + { title: "sidebar.titles.schedule", url: "/viewSchedule", icon: CalendarRange }, + { title: "sidebar.titles.my_assigned_bookings", url: "/booking", icon: CalendarClock }, + { title: "sidebar.titles.inventory", url: "/inventory", icon: PackageOpen }, + { title: "sidebar.titles.notifications", url: "/notification", icon: Bell }, ]; diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..659ccf7 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,27 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enTranslations from '../locales/en/translation.json'; +import viTranslations from '../locales/vi/translation.json'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { translation: enTranslations }, + vi: { translation: viTranslations }, + }, + fallbackLng: 'en', + supportedLngs: ['en', 'vi'], + interpolation: { + escapeValue: false, // React already safeguards from xss + }, + detection: { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + } + }); + +export default i18n; diff --git a/src/index.css b/src/index.css index 6458db0..7ce8e7b 100644 --- a/src/index.css +++ b/src/index.css @@ -12,17 +12,17 @@ --color-purple-primary: #6b4692; --color-purple-light: #e7d0ff; --color-purple-landing: #b5a2c8; - --color-sub-text: #999999; - --color-primary-gray: #71717a; - --color-sub-feedback: #797979; + --color-sub-text: #737373; + --color-primary-gray: #52525b; + --color-sub-feedback: #595959; /* Slate */ --color-slate-100: #f5f5f5; --color-slate-50: #f8fafc; /* Gray - Light Mode */ - --color-gray-primary: #71717a; - --color-gray-text-header: #404040; + --color-gray-primary: #52525b; + --color-gray-text-header: #262626; /* Brown - Light Mode */ --color-brown-primary: #451a03; @@ -40,12 +40,12 @@ --color-purple-primary-dark: #e7d0ff; --color-purple-secondary-dark: #3d2a5c; --color-purple-landing-dark: #7a6b8a; - --color-sub-text-dark: #b0b0b0; + --color-sub-text-dark: #d4d4d8; --color-slate-100-dark: #2a2a2a; - --color-gray-primary-dark: #a0a0a0; - --color-gray-text-header-dark: #e0e0e0; + --color-gray-primary-dark: #e4e4e7; + --color-gray-text-header-dark: #fafafa; --color-brown-primary-dark: #8b4513; @@ -84,14 +84,14 @@ --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); + --muted: oklch(0.96 0 0); + --muted-foreground: oklch(0.40 0 0); + --accent: oklch(0.96 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --border: oklch(0.85 0 0); + --input: oklch(0.85 0 0); + --ring: oklch(0.60 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); @@ -273,23 +273,23 @@ button:focus-visible { } .dark { - --background: oklch(0.145 0 0); + --background: oklch(0.12 0 0); --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); + --card: oklch(0.16 0 0); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); + --popover: oklch(0.16 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); + --primary-foreground: oklch(0.12 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); + --muted: oklch(0.20 0 0); + --muted-foreground: oklch(0.85 0 0); + --accent: oklch(0.20 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); + --border: oklch(1 0 0 / 25%); + --input: oklch(1 0 0 / 30%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 085e038..367742b 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/side-bar/AppSideBar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import NotificationsPopover from "@/pages/notification/components/NotificationPopover"; import { Outlet, useLocation } from "react-router-dom"; +import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher"; export default function MainLayout() { const location = useLocation(); @@ -15,8 +16,11 @@ export default function MainLayout() { size={"icon"} className="md:hidden fixed top-3 right-5 h-8 w-8 z-50" /> -
- {!isNotificationPage && } +
+
+ + {!isNotificationPage && } +
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json new file mode 100644 index 0000000..bc830bb --- /dev/null +++ b/src/locales/en/translation.json @@ -0,0 +1,256 @@ +{ + "sidebar": { + "role": { + "admin": "Admin", + "staff": "Staff", + "technician": "Technician", + "customer": "Customer" + }, + "view_profile": "View Profile", + "titles": { + "dashboard": "Dashboard", + "vehicles": "Customers & Vehicles", + "my_vehicles": "My Vehicles", + "employees": "Employee Management", + "staffs": "Staffs", + "technicians": "Technicians", + "shifts": "Work Shifts Management", + "inventory": "Inventory", + "memberships": "Memberships", + "notifications": "Notifications", + "booking": "Service Booking", + "chat": "Chat Box", + "support": "Help & Support", + "schedule": "Work Schedule", + "booking_management": "Booking Management", + "my_assigned_bookings": "My Assigned Bookings", + "profile": "Profile" + } + }, + "notifications": { + "view_tooltip": "View Notifications", + "header": "Your Notifications", + "mark_all_read": "Mark all read", + "tabs": { + "all": "All", + "unread": "Unread", + "read": "Read" + }, + "sections": { + "today": "Today", + "yesterday": "Yesterday" + }, + "empty": { + "title": "No Notifications", + "unread_desc": "No unread notifications from the last 48 hours.", + "read_desc": "No read notifications from the last 48 hours.", + "all_desc": "You don't have any notifications from the last 48 hours." + }, + "footer": { + "manage": "Manage Notifications", + "view_all": "View all notifications" + } + }, + "auth": { + "login": { + "welcome": "Welcome Back !", + "desc": "If you don't have an account register", + "register_link": "Register here", + "email_label": "Email", + "email_placeholder": "Enter your email", + "password_label": "Password", + "password_placeholder": "Enter your password", + "forgot_password": "Forgot Password", + "sign_in": "Sign In", + "google_login": "Continue with Google" + }, + "register": { + "welcome": "Welcome Guest !", + "desc": "If you already have an account", + "login_link": "Login here", + "first_name": "First Name", + "first_name_placeholder": "Enter your first name", + "last_name": "Last Name", + "last_name_placeholder": "Enter your last name", + "phone": "Phone", + "phone_placeholder": "Enter your phone number", + "address": "Address", + "address_placeholder": "Enter your address", + "password": "Password", + "password_placeholder": "Enter your password", + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Confirm your password", + "register_button": "Register" + }, + "verification": { + "title": "Your account is not verified.", + "desc": "Check your email inbox or spam folder. If not received, resend the email.", + "resend_button": "Resend" + } + }, + "profile": { + "sections": { + "information": "Information", + "preferences": "Preferences", + "details": "Detailed Settings" + }, + "labels": { + "email": "Email", + "status": "Status", + "modes": "Modes", + "no_data": "No data available", + "first_name": "First Name", + "last_name": "Last Name", + "phone": "Phone", + "address": "Address", + "old_password": "Old Password", + "new_password": "New Password", + "confirm_new_password": "Confirm New Password" + }, + "placeholders": { + "first_name": "Enter your first name", + "last_name": "Enter your last name", + "phone": "Enter your phone number", + "address": "Enter your address", + "old_password": "Enter your old password", + "new_password": "Enter your new password", + "confirm_new_password": "Confirm your new password" + }, + "actions": { + "logout": "Logout", + "save_changes": "Save Changes", + "forgot_password": "Forgot Password?" + } + }, + "errors": { + "unauthorized": "You don't have permission to access this page.", + "not_found": { + "title": "404", + "subtitle": "Page Not Found", + "description": "The page you are looking for does not exist.", + "go_home": "Go to Home" + } + }, + "dashboard": { + "welcome_back": "Welcome back, {{name}}", + "admin": { + "total_revenue": "Total Revenue", + "verified_customers": "Verified Customers", + "verified_employees": "Verified Employees", + "total_service_center": "Total Service Center", + "view_service_center_list": "View Service Center List", + "service_center_list": "Service Center List", + "trending": { + "title": "Customer Purchase Trends", + "desc": "Understand customer behavior through top-purchased services and packages.", + "services": "Services", + "packages": "Maintenance Packages", + "memberships": "Membership Plans", + "view_list": "View List", + "services_list": "Services List", + "packages_list": "Packages List", + "memberships_list": "Memberships List", + "showing_top": "Showing all top {{type}} customers preferred.", + "no_data_analysis": "No data available for analysis", + "top_service": "Top service:", + "top_services": "Top services:", + "most_chosen": "Most chosen:", + "top_tier": "Top tier:" + }, + "inventory": { + "title": "Inventory Status", + "management_title": "Inventory Management", + "total_items": "Total Items", + "total_value": "Total Value", + "low_stock_items": "Low Stock Items", + "no_low_stock": "No low stock items", + "categories": "Categories", + "across_products": "Across {{count}} products", + "current_value": "Current inventory value", + "need_restocking": "Need restocking", + "product_categories": "Product categories", + "items_list": "Inventory Items", + "add_new": "Add New Item", + "search_placeholder": "Name, Category", + "name": "Part Name", + "category": "Category", + "quantity": "Quantity", + "price": "Price", + "status": "Status", + "status_types": { + "available": "In Stock", + "low_stock": "Low Stock", + "discontinued": "Discontinued" + } + }, + "charts": { + "revenue_overview": "Revenue Overview", + "revenue_desc": "Showing total revenue for the selected period", + "select_period": "Select period", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "last_3_months": "Last 3 months", + "center_performance": "Top Service Center Performance", + "center_desc": "Booking volume and revenue across service centers", + "bookings": "Bookings", + "revenue": "Revenue" + } + }, + "staff": { + "total_customers": "Total Customers", + "new_tickets": "New Tickets", + "work_schedule": "Work Schedule", + "events_on": "Events on {{date}}", + "no_events": "No events for this day." + }, + "customer": { + "total_spending": "Total Spending", + "total_spending_desc": "Your total spending this year.", + "average_spending": "Average Spending", + "average_spending_desc": "Your average spending per transaction.", + "peak_spending": "Peak Spending", + "peak_spending_desc": "Your highest spending in a single period.", + "active_bookings": "Active Bookings", + "total_bookings": "Total Bookings", + "pending": "Pending", + "in_progress": "In Progress", + "finished": "Finished", + "finished_tooltip": "Includes Completed (unpaid) and Checked Out (paid) bookings" + }, + "technician": { + "tasks_completed": "Tasks Completed", + "current_task": "Current Task", + "technician_schedule": "Technician Schedule", + "shifts_on": "Shifts on {{date}}", + "no_shifts": "No assigned shifts for this day." + }, + "bookings": { + "recent": "Recent Bookings", + "booking_id": "Booking ID", + "date": "Date", + "service_center": "Service Center", + "status": "Status", + "total": "Total", + "actions": "Actions", + "search_placeholder": "Booking ID", + "status_types": { + "pending": "Pending", + "assigned": "Assigned", + "checked_in": "Checked In", + "completed": "Completed" + } + }, + "common": { + "no_data": "No data available", + "unnamed": "Unnamed" + } + }, + "header": { + "welcome": "Welcome" + }, + "language_switcher": { + "select_language": "Select Language", + "english": "English", + "vietnamese": "Vietnamese" + } +} diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json new file mode 100644 index 0000000..47ebaeb --- /dev/null +++ b/src/locales/vi/translation.json @@ -0,0 +1,256 @@ +{ + "sidebar": { + "role": { + "admin": "Quản trị viên", + "staff": "Nhân viên", + "technician": "Kỹ thuật viên", + "customer": "Khách hàng" + }, + "view_profile": "Xem hồ sơ", + "titles": { + "dashboard": "Bảng điều khiển", + "vehicles": "Khách hàng & Xe", + "my_vehicles": "Xe của tôi", + "employees": "Quản lý nhân viên", + "staffs": "Nhân viên", + "technicians": "Kỹ thuật viên", + "shifts": "Quản lý ca làm việc", + "inventory": "Kho hàng", + "memberships": "Gói thành viên", + "notifications": "Thông báo", + "booking": "Đặt dịch vụ", + "chat": "Hộp thoại", + "support": "Trợ giúp & Hỗ trợ", + "schedule": "Lịch làm việc", + "booking_management": "Quản lý đặt lịch", + "my_assigned_bookings": "Đơn đặt của tôi", + "profile": "Hồ sơ" + } + }, + "notifications": { + "view_tooltip": "Xem thông báo", + "header": "Thông báo của bạn", + "mark_all_read": "Đánh dấu tất cả là đã đọc", + "tabs": { + "all": "Tất cả", + "unread": "Chưa đọc", + "read": "Đã đọc" + }, + "sections": { + "today": "Hôm nay", + "yesterday": "Hôm qua" + }, + "empty": { + "title": "Không có thông báo", + "unread_desc": "Không có thông báo chưa đọc nào trong 48 giờ qua.", + "read_desc": "Không có thông báo đã đọc nào trong 48 giờ qua.", + "all_desc": "Bạn không có bất kỳ thông báo nào trong 48 giờ qua." + }, + "footer": { + "manage": "Quản lý thông báo", + "view_all": "Xem tất cả thông báo" + } + }, + "auth": { + "login": { + "welcome": "Chào mừng trở lại !", + "desc": "Nếu bạn chưa có tài khoản, hãy đăng ký", + "register_link": "Đăng ký tại đây", + "email_label": "Email", + "email_placeholder": "Nhập email của bạn", + "password_label": "Mật khẩu", + "password_placeholder": "Nhập mật khẩu của bạn", + "forgot_password": "Quên mật khẩu", + "sign_in": "Đăng nhập", + "google_login": "Tiếp tục với Google" + }, + "register": { + "welcome": "Chào mừng khách hàng !", + "desc": "Nếu bạn đã có tài khoản", + "login_link": "Đăng nhập tại đây", + "first_name": "Tên", + "first_name_placeholder": "Nhập tên của bạn", + "last_name": "Họ", + "last_name_placeholder": "Nhập họ của bạn", + "phone": "Số điện thoại", + "phone_placeholder": "Nhập số điện thoại của bạn", + "address": "Địa chỉ", + "address_placeholder": "Nhập địa chỉ của bạn", + "password": "Mật khẩu", + "password_placeholder": "Nhập mật khẩu của bạn", + "confirm_password": "Xác nhận mật khẩu", + "confirm_password_placeholder": "Xác nhận mật khẩu của bạn", + "register_button": "Đăng ký" + }, + "verification": { + "title": "Tài khoản của bạn chưa được xác minh.", + "desc": "Kiểm tra hộp thư đến hoặc thư rác của bạn. Nếu không nhận được, hãy gửi lại email.", + "resend_button": "Gửi lại" + } + }, + "profile": { + "sections": { + "information": "Thông tin", + "preferences": "Tùy chọn", + "details": "Cài đặt chi tiết" + }, + "labels": { + "email": "Email", + "status": "Trạng thái", + "modes": "Chế độ", + "no_data": "Không có dữ liệu", + "first_name": "Tên", + "last_name": "Họ", + "phone": "Số điện thoại", + "address": "Địa chỉ", + "old_password": "Mật khẩu cũ", + "new_password": "Mật khẩu mới", + "confirm_new_password": "Xác nhận mật khẩu mới" + }, + "placeholders": { + "first_name": "Nhập tên của bạn", + "last_name": "Nhập họ của bạn", + "phone": "Nhập số điện thoại của bạn", + "address": "Nhập địa chỉ của bạn", + "old_password": "Nhập mật khẩu cũ của bạn", + "new_password": "Nhập mật khẩu mới của bạn", + "confirm_new_password": "Nhập lại mật khẩu mới để xác nhận" + }, + "actions": { + "logout": "Đăng xuất", + "save_changes": "Lưu thay đổi", + "forgot_password": "Quên mật khẩu?" + } + }, + "errors": { + "unauthorized": "Bạn không có quyền truy cập vào trang này.", + "not_found": { + "title": "404", + "subtitle": "Trang không tồn tại", + "description": "Trang bạn đang tìm kiếm không tồn tại.", + "go_home": "Về trang chủ" + } + }, + "dashboard": { + "welcome_back": "Chào mừng trở lại, {{name}}", + "admin": { + "total_revenue": "Tổng doanh thu", + "verified_customers": "Khách hàng đã xác minh", + "verified_employees": "Nhân viên đã xác minh", + "total_service_center": "Tổng trung tâm dịch vụ", + "view_service_center_list": "Xem danh sách trung tâm dịch vụ", + "service_center_list": "Danh sách trung tâm dịch vụ", + "trending": { + "title": "Xu hướng mua sắm của khách hàng", + "desc": "Hiểu hành vi của khách hàng thông qua các dịch vụ và gói bảo trì được mua nhiều nhất.", + "services": "Dịch vụ", + "packages": "Gói bảo trì", + "memberships": "Gói thành viên", + "view_list": "Xem danh sách", + "services_list": "Danh sách dịch vụ", + "packages_list": "Danh sách gói bảo trì", + "memberships_list": "Danh sách gói thành viên", + "showing_top": "Hiển thị tất cả các {{type}} hàng đầu mà khách hàng ưu tiên.", + "no_data_analysis": "Không có dữ liệu để phân tích", + "top_service": "Dịch vụ hàng đầu:", + "top_services": "Dịch vụ hàng đầu:", + "most_chosen": "Được chọn nhiều nhất:", + "top_tier": "Bậc cao nhất:" + }, + "inventory": { + "title": "Tình trạng kho", + "management_title": "Quản lý kho", + "total_items": "Tổng số mặt hàng", + "total_value": "Tổng giá trị", + "low_stock_items": "Mặt hàng sắp hết", + "no_low_stock": "Không có mặt hàng nào sắp hết", + "categories": "Danh mục", + "across_products": "Trên {{count}} sản phẩm", + "current_value": "Giá trị kho hiện tại", + "need_restocking": "Cần nhập thêm", + "product_categories": "Danh mục sản phẩm", + "items_list": "Danh sách mặt hàng", + "add_new": "Thêm mặt hàng mới", + "search_placeholder": "Tên, Danh mục", + "name": "Tên phụ tùng", + "category": "Danh mục", + "quantity": "Số lượng", + "price": "Giá", + "status": "Trạng thái", + "status_types": { + "available": "Còn hàng", + "low_stock": "Sắp hết hàng", + "discontinued": "Ngừng kinh doanh" + } + }, + "charts": { + "revenue_overview": "Tổng quan doanh thu", + "revenue_desc": "Hiển thị tổng doanh thu cho khoảng thời gian đã chọn", + "select_period": "Chọn khoảng thời gian", + "last_7_days": "7 ngày qua", + "last_30_days": "30 ngày qua", + "last_3_months": "3 tháng qua", + "center_performance": "Hiệu suất trung tâm dịch vụ hàng đầu", + "center_desc": "Số lượng đơn đặt chỗ và doanh thu trên các trung tâm dịch vụ", + "bookings": "Đặt chỗ", + "revenue": "Doanh thu" + } + }, + "staff": { + "total_customers": "Tổng số khách hàng", + "new_tickets": "Phiếu mới", + "work_schedule": "Lịch làm việc", + "events_on": "Sự kiện ngày {{date}}", + "no_events": "Không có sự kiện nào cho ngày này." + }, + "customer": { + "total_spending": "Tổng chi tiêu", + "total_spending_desc": "Tổng chi tiêu của bạn trong năm nay.", + "average_spending": "Chi tiêu trung bình", + "average_spending_desc": "Chi tiêu trung bình của bạn trên mỗi giao dịch.", + "peak_spending": "Chi tiêu cao nhất", + "peak_spending_desc": "Chi tiêu cao nhất của bạn trong một khoảng thời gian.", + "active_bookings": "Đơn đặt chỗ hoạt động", + "total_bookings": "Tổng số đơn đặt", + "pending": "Chờ xử lý", + "in_progress": "Đang thực hiện", + "finished": "Đã hoàn thành", + "finished_tooltip": "Bao gồm các đơn đặt đã hoàn thành (chưa thanh toán) và đã trả xe (đã thanh toán)" + }, + "technician": { + "tasks_completed": "Nhiệm vụ đã hoàn thành", + "current_task": "Nhiệm vụ hiện tại", + "technician_schedule": "Lịch trình kỹ thuật viên", + "shifts_on": "Ca làm việc ngày {{date}}", + "no_shifts": "Không có ca làm việc nào được phân công cho ngày này." + }, + "bookings": { + "recent": "Đặt chỗ gần đây", + "booking_id": "Mã đặt chỗ", + "date": "Ngày", + "service_center": "Trung tâm dịch vụ", + "status": "Trạng thái", + "total": "Tổng cộng", + "actions": "Hành động", + "search_placeholder": "Mã đặt chỗ", + "status_types": { + "pending": "Đang chờ", + "assigned": "Đã phân công", + "checked_in": "Đã check-in", + "completed": "Đã hoàn thành" + } + }, + "common": { + "no_data": "Không có dữ liệu", + "unnamed": "Chưa đặt tên" + } + }, + "header": { + "welcome": "Chào mừng" + }, + "language_switcher": { + "select_language": "Ngôn ngữ", + "english": "Tiếng Anh", + "vietnamese": "Tiếng Việt" + } +} diff --git a/src/main.tsx b/src/main.tsx index 5030f57..d4e3bc4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; +import "./i18n/config"; import App from "./App.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import AuthProvider from "./contexts/AuthContext.tsx"; diff --git a/src/pages/auth/components/LoginForm.tsx b/src/pages/auth/components/LoginForm.tsx index d20bd51..c42d113 100644 --- a/src/pages/auth/components/LoginForm.tsx +++ b/src/pages/auth/components/LoginForm.tsx @@ -14,12 +14,14 @@ import { useState } from "react" import { Eye, EyeOff } from "lucide-react" import { CgGoogle } from "react-icons/cg"; import { Link } from "react-router-dom" +import { useTranslation } from "react-i18next"; type LoginFormProps = { form: ReturnType>; onSubmit: (data: LoginFormData) => void; } export const LoginForm = ({ form, onSubmit }: LoginFormProps) => { + const { t } = useTranslation(); const [showPassword, setShowPassword] = useState(false) const togglePasswordVisibility = () => { setShowPassword(!showPassword) @@ -34,9 +36,9 @@ export const LoginForm = ({ form, onSubmit }: LoginFormProps) => { name="email" render={({ field }) => ( - Email + {t("auth.login.email_label")} - + @@ -47,11 +49,11 @@ export const LoginForm = ({ form, onSubmit }: LoginFormProps) => { name="password" render={({ field }) => ( - Password + {t("auth.login.password_label")}
{ />
- Forgot Password + {t("auth.login.forgot_password")}
- +
diff --git a/src/pages/auth/components/VerificationAlert.tsx b/src/pages/auth/components/VerificationAlert.tsx index bf00631..f352184 100644 --- a/src/pages/auth/components/VerificationAlert.tsx +++ b/src/pages/auth/components/VerificationAlert.tsx @@ -7,8 +7,10 @@ import { CheckCircle2Icon, X } from "lucide-react" -export default function VerificationAlert({ }) { +import { useTranslation } from "react-i18next"; +export default function VerificationAlert({ }) { + const { t } = useTranslation(); const { handleResend } = useLogin() const { setIsNotVerified } = useAuth() @@ -16,10 +18,10 @@ export default function VerificationAlert({ }) { setIsNotVerified(false)} /> - Your account is not verified. + {t("auth.verification.title")} - Check your email inbox or spam folder. If not received, resend the email. - + {t("auth.verification.desc")} + ) diff --git a/src/pages/auth/login/index.tsx b/src/pages/auth/login/index.tsx index 35d372b..a26fee9 100644 --- a/src/pages/auth/login/index.tsx +++ b/src/pages/auth/login/index.tsx @@ -8,8 +8,10 @@ import { useWindowSize } from "@uidotdev/usehooks"; import useLogin from "@/services/auth/hooks/useLogin" import VerificationAlert from "../components/VerificationAlert" import Loading from "@/components/Loading" +import { useTranslation } from "react-i18next"; const LoginPage = () => { + const { t } = useTranslation(); const { auth, isNotVerified } = useAuth() const { form, onSubmit } = useLogin() const { height, width = 0 } = useWindowSize() @@ -25,8 +27,6 @@ const LoginPage = () => { } }, [width, height]) - - if (auth.isAuthenticated) { return } @@ -44,10 +44,10 @@ const LoginPage = () => {

- Welcome Back ! + {t("auth.login.welcome")}

-

If you don't have an account register

-

You can Register here

+

{t("auth.login.desc")}

+

{t("auth.login.register_link_text", "You can ")}{t("auth.login.register_link")}

diff --git a/src/pages/auth/register/index.tsx b/src/pages/auth/register/index.tsx index f10c3d9..3625726 100644 --- a/src/pages/auth/register/index.tsx +++ b/src/pages/auth/register/index.tsx @@ -7,14 +7,17 @@ import loginImg from "@/assets/login-img.png"; import { Link, Navigate } from "react-router-dom"; import { useAuth } from "@/contexts/AuthContext"; +import { useTranslation } from "react-i18next"; + export default function RegisterPage() { + const { t } = useTranslation(); const { form, onSubmit } = useRegister(); const { height, width = 0 } = useWindowSize(); const [isMobile, setIsMobile] = useState(false); const { auth } = useAuth(); if (auth.isAuthenticated) { - return ; + return ; } useEffect(() => { @@ -35,12 +38,12 @@ export default function RegisterPage() {
-

Welcome Guest !

-

If you already have an account

+

{t("auth.register.welcome")}

+

{t("auth.register.desc")}

- You can{" "} + {t("auth.register.login_link_text", "You can ")}{" "} - Login here + {t("auth.register.login_link")}

diff --git a/src/pages/dashboard/components/AdminDashboard.tsx b/src/pages/dashboard/components/AdminDashboard.tsx index 9e874f0..2660bd4 100644 --- a/src/pages/dashboard/components/AdminDashboard.tsx +++ b/src/pages/dashboard/components/AdminDashboard.tsx @@ -6,11 +6,13 @@ import { InventoryStatusCard } from "./card/InventoryStatusCard"; import { OverviewAdmin } from "./card/OverviewCard"; import { TrendingPurchaseCard } from "./card/TrendingCard"; import CenterBarChar from "./chart/CenterBarChart"; +import { useTranslation } from "react-i18next"; export default function AdminDashboard() { + const { t } = useTranslation(); return (
- + diff --git a/src/pages/dashboard/components/CustomerDashboard.tsx b/src/pages/dashboard/components/CustomerDashboard.tsx index 4bf0355..6d36c8d 100644 --- a/src/pages/dashboard/components/CustomerDashboard.tsx +++ b/src/pages/dashboard/components/CustomerDashboard.tsx @@ -11,15 +11,20 @@ import SpendingSummaryBarChart from "./chart/SpendingSummaryBarChart"; import RecentBookingTable from "./table/RecentBookingTable"; import MyMembershipCard from "./card/MyMembershipCard"; +import { useTranslation } from "react-i18next"; + export default function CustomerDashboard() { + const { t } = useTranslation(); const { auth } = useAuth(); const { data, isLoading } = useGetCustomerDashboardData(); + const userName = `${auth?.user?.profile?.firstName ?? ""} ${auth?.user?.profile?.lastName ?? ""}`.trim(); + return (
diff --git a/src/pages/dashboard/components/StaffDashboard.tsx b/src/pages/dashboard/components/StaffDashboard.tsx index 74e2bf3..85d31fe 100644 --- a/src/pages/dashboard/components/StaffDashboard.tsx +++ b/src/pages/dashboard/components/StaffDashboard.tsx @@ -8,15 +8,20 @@ import { useGetStaffDashboardData } from "@/services/dashboard/queries/staff"; import type { StaffDashboardData } from "@/types/models/dashboard"; import { OverviewStaff } from "./card/OverviewCard"; +import { useTranslation } from "react-i18next"; + export default function StaffDashboard() { + const { t } = useTranslation(); const { auth } = useAuth(); const { data, isLoading } = useGetStaffDashboardData(); + const userName = `${auth?.user?.profile?.firstName ?? ""} ${auth?.user?.profile?.lastName ?? ""}`.trim(); + return (
diff --git a/src/pages/dashboard/components/TechnicianDashboard.tsx b/src/pages/dashboard/components/TechnicianDashboard.tsx index d379b25..fe79e8a 100644 --- a/src/pages/dashboard/components/TechnicianDashboard.tsx +++ b/src/pages/dashboard/components/TechnicianDashboard.tsx @@ -6,17 +6,19 @@ import TechnicianBookingStatisticCard from "./chart/BookingStatisticPieChart"; import TechnicianWorkSchedule from "./card/TechnicianWorkSchedule"; import { InventoryStatusCard } from "./card/InventoryStatusCard"; +import { useTranslation } from "react-i18next"; + export default function TechnicianDashboard() { + const { t } = useTranslation(); const { auth } = useAuth(); + const userName = `${auth?.user?.profile?.firstName ?? ""} ${auth?.user?.profile?.lastName ?? ""}`.trim(); return (
diff --git a/src/pages/dashboard/components/card/InventoryStatusCard.tsx b/src/pages/dashboard/components/card/InventoryStatusCard.tsx index 4888917..43c31b0 100644 --- a/src/pages/dashboard/components/card/InventoryStatusCard.tsx +++ b/src/pages/dashboard/components/card/InventoryStatusCard.tsx @@ -4,8 +4,10 @@ import { OctagonAlert, Package } from "lucide-react"; import { LowStockProgressBar } from "../chart/LowStockProgressBarList"; import { InventoryBar } from "../chart/InventoryStatusBar"; import { useGetInventoryStatus } from "@/services/dashboard/queries/admin"; +import { useTranslation } from "react-i18next"; export function InventoryStatusCard() { + const { t } = useTranslation(); const { data, isLoading } = useGetInventoryStatus(); if (isLoading) { @@ -59,7 +61,7 @@ export function InventoryStatusCard() { - Inventory Status + {t("dashboard.admin.inventory.title")} @@ -68,13 +70,13 @@ export function InventoryStatusCard() {

- Total Items + {t("dashboard.admin.inventory.total_items")}

{data?.totalItems}

- Total Value + {t("dashboard.admin.inventory.total_value")}

${data?.totalValue.toLocaleString()} @@ -101,12 +103,12 @@ export function InventoryStatusCard() {

- Low Stock Items + {t("dashboard.admin.inventory.low_stock_items")}
{(data?.lowStockItems?.length ?? 0) > 0 ? ( ) : ( -

No low stock items

+

{t("dashboard.admin.inventory.no_low_stock")}

)}
diff --git a/src/pages/dashboard/components/card/OverviewCard.tsx b/src/pages/dashboard/components/card/OverviewCard.tsx index 37904e4..2d6e6fd 100644 --- a/src/pages/dashboard/components/card/OverviewCard.tsx +++ b/src/pages/dashboard/components/card/OverviewCard.tsx @@ -21,6 +21,7 @@ import { import type { ServiceCenter } from "@/types/models/center"; import { useState } from "react"; import { useGetServiceCenterList } from "@/services/manager/queries"; +import { useTranslation } from "react-i18next"; type DetailCenterListDialogProps = { open: boolean; @@ -32,36 +33,40 @@ const DetailsCenterListDialog = ({ open, onOpenChange, items, -}: DetailCenterListDialogProps) => ( - - - - Service Center List - -
    - {items?.length > 0 ? ( - items.map((item, index) => ( -
  • - - {item.name ?? item.name ?? "Unnamed"} - - {item.address} -
  • - )) - ) : ( -

    - No data available. -

    - )} -
-
-
-); +}: DetailCenterListDialogProps) => { + const { t } = useTranslation(); + return ( + + + + {t("dashboard.admin.service_center_list")} + +
    + {items?.length > 0 ? ( + items.map((item, index) => ( +
  • + + {item.name ?? item.name ?? t("dashboard.common.unnamed")} + + {item.address} +
  • + )) + ) : ( +

    + {t("dashboard.common.no_data")} +

    + )} +
+
+
+ ); +}; export function OverviewAdmin() { + const { t } = useTranslation(); const { data, isLoading } = useGetAdminOverview(); const [openCenterList, setOpenCenterList] = useState(false); const { data: serviceCenters } = useGetServiceCenterList(); @@ -85,7 +90,7 @@ export function OverviewAdmin() { return (
- +
setOpenCenterList(!openCenterList)} @@ -135,6 +140,7 @@ export function OverviewStaff({ data: StaffDashboardData; isLoading: boolean; }) { + const { t } = useTranslation(); return (
{isLoading ? ( @@ -150,12 +156,12 @@ export function OverviewStaff({ ) : ( <> diff --git a/src/pages/dashboard/components/card/OverviewCardCustomer.tsx b/src/pages/dashboard/components/card/OverviewCardCustomer.tsx index 1e3a834..ad276f1 100644 --- a/src/pages/dashboard/components/card/OverviewCardCustomer.tsx +++ b/src/pages/dashboard/components/card/OverviewCardCustomer.tsx @@ -3,6 +3,7 @@ import type { CustomerDashboardData } from "@/types/models/dashboard"; import { Calendar, PlayCircle, Clock, CheckCircle } from "lucide-react"; import TotalCardCustomer from "./TotalCardCustomer"; import { TooltipWrapper } from "@/components/TooltipWrapper"; +import { useTranslation } from "react-i18next"; export default function OverviewCardCustomer({ data, @@ -11,6 +12,7 @@ export default function OverviewCardCustomer({ data: CustomerDashboardData | undefined; isLoading: boolean; }) { + const { t } = useTranslation(); const statusList = data?.bookingStatusSummary ?? []; const pending = statusList.find((s) => s.status === "PENDING")?.count ?? 0; @@ -33,30 +35,30 @@ export default function OverviewCardCustomer({ ) : ( <> - +
{ - const raw = data ?? []; - return raw.map((item) => ({ + const raw = (data as any) ?? []; + return raw.map((item: any) => ({ date: item.date, time: `${item.shift.startTime.slice(0, 5)} - ${item.shift.endTime.slice(0, 5)}`, centerName: item.shift.serviceCenter.name, @@ -25,7 +27,7 @@ export default function TechnicianWorkSchedule({ }, [data]); const selectedEvents = events.filter( - (e) => e.date === format(date, "yyyy-MM-dd") + (e: any) => e.date === format(date, "yyyy-MM-dd") ); return ( @@ -50,17 +52,15 @@ export default function TechnicianWorkSchedule({ <> - Technician Schedule + {t("technician.technician_schedule")} - {/* ✅ Responsive fix: 1 column on mobile, 2 columns from md+ */} - {/* Calendar Section */}
{ + oneShift: (day: Date) => { const dayStr = format(day, "yyyy-MM-dd"); - return events.filter((e) => e.date === dayStr).length === 1; + return events.filter((e: any) => e.date === dayStr).length === 1; }, - twoShifts: (day) => { + twoShifts: (day: Date) => { const dayStr = format(day, "yyyy-MM-dd"); - return events.filter((e) => e.date === dayStr).length >= 2; + return events.filter((e: any) => e.date === dayStr).length >= 2; }, }} modifiersClassNames={{ @@ -89,15 +89,14 @@ export default function TechnicianWorkSchedule({ />
- {/* Shift list */}

- Shifts on {format(date, "dd MMM yyyy")} + {t("technician.shifts_on", { date: format(date, "dd MMM yyyy") })}

{selectedEvents.length > 0 ? (
- {selectedEvents.map((event, idx) => ( + {selectedEvents.map((event: any, idx: number) => (
) : (

- No assigned shifts for this day. + {t("technician.no_shifts")}

)}
diff --git a/src/pages/dashboard/components/card/TrendingCard.tsx b/src/pages/dashboard/components/card/TrendingCard.tsx index 5bc0f7a..517fd77 100644 --- a/src/pages/dashboard/components/card/TrendingCard.tsx +++ b/src/pages/dashboard/components/card/TrendingCard.tsx @@ -23,6 +23,7 @@ import { useGetTrendingPurchase } from "@/services/dashboard/queries/admin"; import type { ServiceData } from "@/types/models/dashboard"; import { TooltipWrapper } from "@/components/TooltipWrapper"; import { ChartPieIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; type DialogType = "services" | "packages" | "memberships"; @@ -38,41 +39,47 @@ const DetailsDialog: React.FC = ({ onOpenChange, title, items, -}) => ( - - - - {title} - - Showing all top {title.toLowerCase()} customers preferred. - - -
    - {items?.length ? ( - items.map((item, index) => ( -
  • - - {item.name ?? "Unnamed"} - - - {item.value ?? ""} - -
  • - )) - ) : ( -

    - No data available. -

    - )} -
-
-
-); +}) => { + const { t } = useTranslation(); + return ( + + + + {title} + + {t("dashboard.admin.trending.showing_top", { + type: title.toLowerCase(), + })} + + +
    + {items?.length ? ( + items.map((item, index) => ( +
  • + + {item.name ?? t("dashboard.common.unnamed")} + + + {item.value ?? ""} + +
  • + )) + ) : ( +

    + {t("dashboard.common.no_data")} +

    + )} +
+
+
+ ); +}; export function TrendingPurchaseCard() { + const { t } = useTranslation(); const { data, isLoading } = useGetTrendingPurchase(); const [dialog, setDialog] = React.useState<{ open: boolean; @@ -96,12 +103,12 @@ export function TrendingPurchaseCard() {

- No data available for analysis + {t("dashboard.admin.trending.no_data_analysis")}

)} - + - Customer Purchase Trends + {t("dashboard.admin.trending.title")} - Understand customer behavior through top-purchased services and - packages. + {t("dashboard.admin.trending.desc")} @@ -185,19 +191,19 @@ export function TrendingPurchaseCard() { type: "services" as DialogType, dataItems: data?.services ?? [], chart: , - label: "Services", - topText: `Top ${ + label: t("dashboard.admin.trending.services"), + topText: `${ (data?.mostPopularService?.length ?? 0) > 1 - ? "services:" - : "service:" + ? t("dashboard.admin.trending.top_services") + : t("dashboard.admin.trending.top_service") } ${data?.mostPopularService?.join(", ") ?? ""}`, }, { type: "packages" as DialogType, dataItems: data?.packages ?? [], chart: , - label: "Maintenance Packages", - topText: `Most chosen: ${data?.mostPopularPackage?.join(", ") ?? ""}`, + label: t("dashboard.admin.trending.packages"), + topText: `${t("dashboard.admin.trending.most_chosen")} ${data?.mostPopularPackage?.join(", ") ?? ""}`, }, { type: "memberships" as DialogType, @@ -205,8 +211,8 @@ export function TrendingPurchaseCard() { chart: ( ), - label: "Membership Plans", - topText: `Top tier: ${data?.mostPopularMembership?.join(", ") ?? ""}`, + label: t("dashboard.admin.trending.memberships"), + topText: `${t("dashboard.admin.trending.top_tier")} ${data?.mostPopularMembership?.join(", ") ?? ""}`, }, ].map((item, index) => (
{ - const raw = data ?? []; - return raw.map((item) => ({ + const raw = (data as any) ?? []; + return raw.map((item: any) => ({ date: item.date, time: `${item.shift.startTime.slice(0, 5)} - ${item.shift.endTime.slice(0, 5)}`, centerName: item.shift.serviceCenter.name, @@ -25,7 +27,7 @@ export default function WeeklyWorkSchedule({ }, [data]); const selectedEvents = events.filter( - (e) => e.date === format(date, "yyyy-MM-dd"), + (e: any) => e.date === format(date, "yyyy-MM-dd"), ); return ( @@ -51,7 +53,7 @@ export default function WeeklyWorkSchedule({ <> - Work Schedule + {t("staff.work_schedule")} @@ -59,7 +61,7 @@ export default function WeeklyWorkSchedule({ { + oneShift: (day: Date) => { const dayStr = format(day, "yyyy-MM-dd"); - return events.filter((e) => e.date === dayStr).length === 1; + return events.filter((e: any) => e.date === dayStr).length === 1; }, - twoShifts: (day) => { + twoShifts: (day: Date) => { const dayStr = format(day, "yyyy-MM-dd"); - return events.filter((e) => e.date === dayStr).length >= 2; + return events.filter((e: any) => e.date === dayStr).length >= 2; }, }} modifiersClassNames={{ @@ -91,12 +93,12 @@ export default function WeeklyWorkSchedule({

- Events on {format(date, "dd MMM yyyy")} + {t("staff.events_on", { date: format(date, "dd MMM yyyy") })}

{selectedEvents.length > 0 ? (
- {selectedEvents.map((event, idx) => ( + {selectedEvents.map((event: any, idx: number) => (
) : (

- No events for this day. + {t("staff.no_events")}

)}
diff --git a/src/pages/dashboard/components/chart/CenterBarChart.tsx b/src/pages/dashboard/components/chart/CenterBarChart.tsx index e08de48..fe83844 100644 --- a/src/pages/dashboard/components/chart/CenterBarChart.tsx +++ b/src/pages/dashboard/components/chart/CenterBarChart.tsx @@ -8,12 +8,14 @@ import { CardDescription, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslation } from "react-i18next"; export default function CenterBarChar() { + const { t } = useTranslation(); const { data, isLoading } = useGetServiceCenterStat(); const chartData = data?.filter( - (item) => Number(item.bookings) > 0 || Number(item.revenue) > 0, + (item: any) => Number(item.bookings) > 0 || Number(item.revenue) > 0, ) ?? []; if (isLoading) { @@ -36,8 +38,8 @@ export default function CenterBarChar() { return ( @@ -55,24 +52,31 @@ export function RevenueChart() { ); } + const chartConfig = { + revenue: { + label: t("dashboard.admin.charts.revenue"), + color: "var(--chart-premium)", + }, + } satisfies ChartConfig; + return (
- Revenue Overview + {t("dashboard.admin.charts.revenue_overview")} - Showing total revenue for the selected period + {t("dashboard.admin.charts.revenue_desc")}
@@ -104,8 +108,8 @@ export function RevenueChart() { axisLine={false} tickMargin={8} minTickGap={32} - tickFormatter={(value) => - new Date(value).toLocaleDateString("en-US", { + tickFormatter={(value: string) => + new Date(value).toLocaleDateString(currentLocale, { month: "short", day: "numeric", }) @@ -115,15 +119,15 @@ export function RevenueChart() { cursor={false} content={ - new Date(value).toLocaleDateString("en-US", { + labelFormatter={(value: string) => + new Date(value).toLocaleDateString(currentLocale, { month: "short", day: "numeric", }) } indicator="dot" - formatter={(value) => [ - `Revenue `, + formatter={(value: any) => [ + `${t("dashboard.admin.charts.revenue")} `, `$${Number(value).toLocaleString()}`, ]} /> diff --git a/src/pages/dashboard/components/table/RecentBookingTable.tsx b/src/pages/dashboard/components/table/RecentBookingTable.tsx index c4bb85a..ac7d965 100644 --- a/src/pages/dashboard/components/table/RecentBookingTable.tsx +++ b/src/pages/dashboard/components/table/RecentBookingTable.tsx @@ -6,8 +6,10 @@ import type { ColumnDef, SortingState } from "@tanstack/react-table"; import { useDebounce } from "@uidotdev/usehooks"; import useBooking from "@/services/booking/hooks/useBooking"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { useTranslation } from "react-i18next"; export default function RecentBookingTable() { + const { t } = useTranslation(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [sorting, setSorting] = useState([]); @@ -39,13 +41,13 @@ export default function RecentBookingTable() { }); const bookings = bookingData?.data ?? []; - const columns = getColumns(); + const columns = getColumns(t); return ( - Recent Bookings + {t("dashboard.bookings.recent")} diff --git a/src/pages/dashboard/components/table/columns.tsx b/src/pages/dashboard/components/table/columns.tsx index e3052cc..5baa3e7 100644 --- a/src/pages/dashboard/components/table/columns.tsx +++ b/src/pages/dashboard/components/table/columns.tsx @@ -5,9 +5,10 @@ import { Badge } from "@/components/ui/badge"; import dayjs from "dayjs"; import BookingTag from "@/components/tag/BookingTag"; import type { Booking } from "@/types/models/booking"; -import ColActions from "./ColAction"; // <-- kiểm tra tên file/exports ở đây +import ColActions from "./ColAction"; +import type { TFunction } from "i18next"; -export const getColumns = () => { +export const getColumns = (t: TFunction) => { const columnHelper = createColumnHelper(); return [ @@ -28,7 +29,7 @@ export const getColumns = () => { // Booking ID columnHelper.accessor("id", { id: "bookingId", - header: "Booking ID", + header: t("dashboard.bookings.booking_id"), size: 150, cell: (info) => ( @@ -36,26 +37,26 @@ export const getColumns = () => { ), enableSorting: false, - meta: { title: "Booking ID" }, + meta: { title: t("dashboard.bookings.booking_id") }, }), // Booking Date columnHelper.accessor("bookingDate", { id: "bookingDate", - header: (info) => , + header: (info) => , cell: (info) => ( {dayjs(info.getValue()).format("HH:mm DD/MM/YYYY")} ), size: 160, - meta: { title: "Booking Date" }, + meta: { title: t("dashboard.bookings.date") }, }), // Service Center columnHelper.accessor((row) => row.center?.name ?? "", { id: "service", - header: (info) => , + header: (info) => , cell: (info) => ( {info.getValue() || "—"} @@ -64,7 +65,7 @@ export const getColumns = () => { enableSorting: true, sortingFn: "alphanumeric", size: 200, - meta: { title: "Service Center" }, + meta: { title: t("dashboard.bookings.service_center") }, }), // Status @@ -73,7 +74,7 @@ export const getColumns = () => { header: ({ column }) => ( column.setFilterValue(v || undefined)} /> @@ -81,10 +82,10 @@ export const getColumns = () => { meta: { filterOptions: ["PENDING", "ASSIGNED", "CHECKED_IN", "COMPLETED"], labelOptions: { - PENDING: "Pending", - ASSIGNED: "Assigned", - CHECKED_IN: "Checked In", - COMPLETED: "Completed", + PENDING: t("dashboard.bookings.status_types.pending"), + ASSIGNED: t("dashboard.bookings.status_types.assigned"), + CHECKED_IN: t("dashboard.bookings.status_types.checked_in"), + COMPLETED: t("dashboard.bookings.status_types.completed"), }, }, cell: (info) => , @@ -94,19 +95,19 @@ export const getColumns = () => { // Total Cost columnHelper.accessor("totalCost", { id: "total", - header: (info) => , + header: (info) => , cell: (info) => ( ${info.getValue()?.toFixed(2) ?? "0.00"} ), size: 100, - meta: { title: "Total" }, + meta: { title: t("dashboard.bookings.total") }, }), columnHelper.display({ id: "actions", - header: "Actions", + header: t("dashboard.bookings.actions"), size: 80, cell: ({ row, table }) => ( - + diff --git a/src/pages/inventory/TechnicianInventoryManagement.tsx b/src/pages/inventory/TechnicianInventoryManagement.tsx index bb7c957..1b6cb73 100644 --- a/src/pages/inventory/TechnicianInventoryManagement.tsx +++ b/src/pages/inventory/TechnicianInventoryManagement.tsx @@ -1,11 +1,13 @@ import DynamicBreadcrumbs from "@/components/DynamicBreadcrumb"; import MainContentLayout from "@/components/MainContentLayout"; import IventoryTable from "./components/technician/table/InventoryTable"; +import { useTranslation } from "react-i18next"; export default function TechnicianInventoryManagement() { + const { t } = useTranslation(); return (
- + diff --git a/src/pages/inventory/components/ItemsListSection.tsx b/src/pages/inventory/components/ItemsListSection.tsx index 4630ee4..abd8ab3 100644 --- a/src/pages/inventory/components/ItemsListSection.tsx +++ b/src/pages/inventory/components/ItemsListSection.tsx @@ -12,7 +12,10 @@ import { AddEditGoodsDialog } from "./AddEditGoodsDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useDebounce } from "@uidotdev/usehooks"; +import { useTranslation } from "react-i18next"; + export default function ItemsListSection() { + const { t } = useTranslation(); const [openAddModal, setOpenAddModal] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); @@ -43,7 +46,7 @@ export default function ItemsListSection() { setFilters((prev) => ({ ...prev, [field]: value })); }; - const columns = getColumns(hanldeFilterChange, filters, categoryList); + const columns = getColumns(hanldeFilterChange, filters, categoryList, t); const { handleAddPartItem, form, isPending } = useInventory(page, pageSize); @@ -63,7 +66,7 @@ export default function ItemsListSection() { ) : ( <>

- Inventory Items + {t("dashboard.admin.inventory.items_list")}

data={rawList} @@ -77,7 +80,7 @@ export default function ItemsListSection() { onPageSizeChange={setPageSize} manualPagination isSearch={true} - searchPlaceholder="Name, Category" + searchPlaceholder={t("dashboard.admin.inventory.search_placeholder")} onSearchChange={setSearchValue} manualSearch sorting={sorting} @@ -90,7 +93,7 @@ export default function ItemsListSection() { variant="outline" autoFocus={false} > - Add New Item + {t("dashboard.admin.inventory.add_new")} } @@ -104,6 +107,7 @@ export default function ItemsListSection() { onOpenChange={(open) => { setOpenAddModal(open); }} + onConfirm={async (data) => { const success = await handleAddPartItem(data); if (success) { diff --git a/src/pages/inventory/components/StatisticsSection.tsx b/src/pages/inventory/components/StatisticsSection.tsx index 38f1de0..f4d5dfb 100644 --- a/src/pages/inventory/components/StatisticsSection.tsx +++ b/src/pages/inventory/components/StatisticsSection.tsx @@ -2,8 +2,10 @@ import StatisticsCard from "./StatisticsCard"; import { Package, DollarSign, AlertTriangle, TrendingUp } from "lucide-react"; import { useGetPartStat } from "@/services/manager/queries"; import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslation } from "react-i18next"; export default function StatisticsSection() { + const { t } = useTranslation(); const { data, isLoading } = useGetPartStat(); return (
@@ -23,28 +25,28 @@ export default function StatisticsSection() { <> )} diff --git a/src/pages/inventory/components/table/columns.tsx b/src/pages/inventory/components/table/columns.tsx index b0e3eab..da51317 100644 --- a/src/pages/inventory/components/table/columns.tsx +++ b/src/pages/inventory/components/table/columns.tsx @@ -7,10 +7,13 @@ import { Badge } from "@/components/ui/badge"; import SortHeader from "@/components/table/SortHeader"; import FilterHeader from "@/components/table/FilterHeader"; +import type { TFunction } from "i18next"; + export const getColumns = ( handleFilterChange: (field: string, value: string) => void, currentFilter: { status: string; categoryName: string }, categoryList: Category[], + t: TFunction, ) => { const columnHelper = createColumnHelper(); @@ -29,10 +32,10 @@ export const getColumns = ( }), columnHelper.accessor("name", { id: "name", - header: (info) => , + header: (info) => , cell: (info) => info.getValue(), meta: { - title: "Part Name", + title: t("dashboard.admin.inventory.name"), }, }), @@ -41,21 +44,21 @@ export const getColumns = ( header: (info) => ( handleFilterChange("categoryName", value)} /> ), cell: (info) => {info.getValue()}, meta: { - title: "Category Name", + title: t("dashboard.admin.inventory.category"), filterVariant: "filterCategory", filterOptions: categoryList.map((c) => c.name), }, }), columnHelper.accessor("quantity", { - header: (info) => , + header: (info) => , cell: ({ row }) => { const item = row.original; @@ -71,14 +74,14 @@ export const getColumns = (
); }, - meta: { title: "Quantity" }, + meta: { title: t("dashboard.admin.inventory.quantity") }, }), columnHelper.accessor("status", { id: "status", header: (info) => ( handleFilterChange("status", value)} /> @@ -86,19 +89,19 @@ export const getColumns = ( cell: (info) => , filterFn: "equals", meta: { - title: "Status", + title: t("dashboard.admin.inventory.status"), filterVariant: "filterStatus", filterOptions: ["AVAILABLE", "OUT_OF_STOCK", "DISCONTINUED"], labelOptions: { - AVAILABLE: "In Stock", - OUT_OF_STOCK: "Low Stock", - DISCONTINUED: "Discontinued", + AVAILABLE: t("dashboard.admin.inventory.status_types.available"), + OUT_OF_STOCK: t("dashboard.admin.inventory.status_types.low_stock"), + DISCONTINUED: t("dashboard.admin.inventory.status_types.discontinued"), }, }, }), columnHelper.accessor("price", { id: "price", - header: (info) => , + header: (info) => , cell: (info) => (

$ @@ -109,7 +112,7 @@ export const getColumns = (

), meta: { - title: "Price", + title: t("dashboard.admin.inventory.price"), }, }), columnHelper.display({ diff --git a/src/pages/inventory/components/technician/table/InventoryTable.tsx b/src/pages/inventory/components/technician/table/InventoryTable.tsx index a247454..7f145b9 100644 --- a/src/pages/inventory/components/technician/table/InventoryTable.tsx +++ b/src/pages/inventory/components/technician/table/InventoryTable.tsx @@ -5,8 +5,10 @@ import type { Part, Category } from "@/types/models/part"; import type { ColumnDef, SortingState } from "@tanstack/react-table"; import { useGetPartList, useGetCategoryList } from "@/services/manager/queries"; import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; export default function InventoryTable() { + const { t } = useTranslation(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [searchValue, setSearchValue] = useState(""); @@ -46,16 +48,16 @@ export default function InventoryTable() { const rawList = data?.data ?? []; const handleFilterChange = (field: string, value: string) => { - setFilters((prev) => ({ ...prev, [field]: value })); + setFilters((prev: any) => ({ ...prev, [field]: value })); }; - const columns = getColumns(handleFilterChange, filters, categoryList); + const columns = getColumns(handleFilterChange, filters, categoryList, t); return (

- Inventory Items + {t("dashboard.admin.inventory.items_list")}

data={rawList} @@ -65,11 +67,11 @@ export default function InventoryTable() { totalPage={data?.totalPages ?? 1} isLoading={isLoading} isFetching={isFetching} - onPageChange={(newPage) => setPage(newPage + 1)} + onPageChange={(newPage: number) => setPage(newPage + 1)} onPageSizeChange={setPageSize} manualPagination isSearch={true} - searchPlaceholder="Name, Category" + searchPlaceholder={t("dashboard.admin.inventory.search_placeholder")} onSearchChange={setSearchValue} manualSearch sorting={sorting} diff --git a/src/pages/inventory/components/technician/table/columns.tsx b/src/pages/inventory/components/technician/table/columns.tsx index b0e3eab..da51317 100644 --- a/src/pages/inventory/components/technician/table/columns.tsx +++ b/src/pages/inventory/components/technician/table/columns.tsx @@ -7,10 +7,13 @@ import { Badge } from "@/components/ui/badge"; import SortHeader from "@/components/table/SortHeader"; import FilterHeader from "@/components/table/FilterHeader"; +import type { TFunction } from "i18next"; + export const getColumns = ( handleFilterChange: (field: string, value: string) => void, currentFilter: { status: string; categoryName: string }, categoryList: Category[], + t: TFunction, ) => { const columnHelper = createColumnHelper(); @@ -29,10 +32,10 @@ export const getColumns = ( }), columnHelper.accessor("name", { id: "name", - header: (info) => , + header: (info) => , cell: (info) => info.getValue(), meta: { - title: "Part Name", + title: t("dashboard.admin.inventory.name"), }, }), @@ -41,21 +44,21 @@ export const getColumns = ( header: (info) => ( handleFilterChange("categoryName", value)} /> ), cell: (info) => {info.getValue()}, meta: { - title: "Category Name", + title: t("dashboard.admin.inventory.category"), filterVariant: "filterCategory", filterOptions: categoryList.map((c) => c.name), }, }), columnHelper.accessor("quantity", { - header: (info) => , + header: (info) => , cell: ({ row }) => { const item = row.original; @@ -71,14 +74,14 @@ export const getColumns = (
); }, - meta: { title: "Quantity" }, + meta: { title: t("dashboard.admin.inventory.quantity") }, }), columnHelper.accessor("status", { id: "status", header: (info) => ( handleFilterChange("status", value)} /> @@ -86,19 +89,19 @@ export const getColumns = ( cell: (info) => , filterFn: "equals", meta: { - title: "Status", + title: t("dashboard.admin.inventory.status"), filterVariant: "filterStatus", filterOptions: ["AVAILABLE", "OUT_OF_STOCK", "DISCONTINUED"], labelOptions: { - AVAILABLE: "In Stock", - OUT_OF_STOCK: "Low Stock", - DISCONTINUED: "Discontinued", + AVAILABLE: t("dashboard.admin.inventory.status_types.available"), + OUT_OF_STOCK: t("dashboard.admin.inventory.status_types.low_stock"), + DISCONTINUED: t("dashboard.admin.inventory.status_types.discontinued"), }, }, }), columnHelper.accessor("price", { id: "price", - header: (info) => , + header: (info) => , cell: (info) => (

$ @@ -109,7 +112,7 @@ export const getColumns = (

), meta: { - title: "Price", + title: t("dashboard.admin.inventory.price"), }, }), columnHelper.display({ diff --git a/src/pages/notfound/index.tsx b/src/pages/notfound/index.tsx index 12dddc8..2677138 100644 --- a/src/pages/notfound/index.tsx +++ b/src/pages/notfound/index.tsx @@ -1,12 +1,16 @@ import { Link } from "react-router-dom" +import { useTranslation } from "react-i18next" export default function NotFound() { + const { t } = useTranslation(); return ( -
-

404

-

Page Not Found

-

The page you are looking for does not exist.

- Go to Home +
+

{t("errors.not_found.title")}

+

{t("errors.not_found.subtitle")}

+

{t("errors.not_found.description")}

+ + {t("errors.not_found.go_home")} +
) } diff --git a/src/pages/notification/components/NotificationPopover.tsx b/src/pages/notification/components/NotificationPopover.tsx index e447940..464ff8f 100644 --- a/src/pages/notification/components/NotificationPopover.tsx +++ b/src/pages/notification/components/NotificationPopover.tsx @@ -22,8 +22,10 @@ import { usePersistentNotificationSocket } from "@/services/notifications/hooks/ import { useAuth } from "@/contexts/AuthContext"; import { Spinner } from "@/components/ui/shadcn-io/spinner"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; export default function NotificationsPopover() { + const { t } = useTranslation(); const { auth } = useAuth(); const navigate = useNavigate(); @@ -113,7 +115,7 @@ export default function NotificationsPopover() { return (

- {title} + {t(`notifications.sections.${title.toLowerCase()}`)}

{list.map((notification) => ( @@ -131,7 +133,7 @@ export default function NotificationsPopover() { return ( - +
@@ -152,7 +154,7 @@ export default function NotificationsPopover() { > {/* Header */}
-

Your Notifications

+

{t("notifications.header")}

{(groupedNotifications.today.length > 0 || groupedNotifications.yesterday.length > 0) && unreadCount > 0 && ( @@ -163,7 +165,7 @@ export default function NotificationsPopover() { onClick={onMarkAsReadAll} > - Mark all read + {t("notifications.mark_all_read")} )}
@@ -175,9 +177,9 @@ export default function NotificationsPopover() { className="flex flex-col h-full" > - All ({totalCount}) - Unread ({unreadCount}) - Read ({readCount}) + {t("notifications.tabs.all")} ({totalCount}) + {t("notifications.tabs.unread")} ({unreadCount}) + {t("notifications.tabs.read")} ({readCount})
@@ -202,14 +204,14 @@ export default function NotificationsPopover() {

- No Notifications + {t("notifications.empty.title")}

{activeTab === "unread" - ? "No unread notifications from the last 48 hours." + ? t("notifications.empty.unread_desc") : activeTab === "read" - ? "No read notifications from the last 48 hours." - : "You don't have any notifications from the last 48 hours."} + ? t("notifications.empty.read_desc") + : t("notifications.empty.all_desc")}

)} @@ -230,14 +232,14 @@ export default function NotificationsPopover() { {/* Footer */}

- Manage Notifications + {t("notifications.footer.manage")}

diff --git a/src/pages/profile/components/ChangePasswordForm.tsx b/src/pages/profile/components/ChangePasswordForm.tsx index 2a0f09f..7ad1154 100644 --- a/src/pages/profile/components/ChangePasswordForm.tsx +++ b/src/pages/profile/components/ChangePasswordForm.tsx @@ -13,6 +13,7 @@ import { Eye, EyeOff } from "lucide-react"; import { useState } from "react"; import { NavLink } from "react-router-dom"; import type { ChangePasswordFormData } from "./profile/libs/schema"; +import { useTranslation } from "react-i18next"; type ChangePasswordFormProps = { form: ReturnType>; @@ -23,6 +24,7 @@ export default function ChangePasswordForm({ form, onSubmit, }: ChangePasswordFormProps) { + const { t } = useTranslation(); const [showPassword, setShowPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmNewPassword, setShowConfirmNewPassword] = useState(false); @@ -36,12 +38,12 @@ export default function ChangePasswordForm({ name="oldPassword" render={({ field }) => ( - Old Password + {t("profile.labels.old_password")}
@@ -62,12 +64,12 @@ export default function ChangePasswordForm({ name="newPassword" render={({ field }) => ( - New Password + {t("profile.labels.new_password")}
@@ -93,13 +95,13 @@ export default function ChangePasswordForm({ render={({ field }) => ( - ConfirmNew Password + {t("profile.labels.confirm_new_password")}
@@ -127,13 +129,13 @@ export default function ChangePasswordForm({ className="w-fit !bg-purple-primary !font-inter !text-white dark:!text-black hover:scale-105 transition-transform duration-150" disabled={!form.formState.isDirty} > - Save Changes + {t("profile.actions.save_changes")} - Forgot Password? + {t("profile.actions.forgot_password")}
diff --git a/src/pages/profile/components/profile/DetailedSettingBox.tsx b/src/pages/profile/components/profile/DetailedSettingBox.tsx index 0d5d80a..b78f007 100644 --- a/src/pages/profile/components/profile/DetailedSettingBox.tsx +++ b/src/pages/profile/components/profile/DetailedSettingBox.tsx @@ -10,7 +10,10 @@ type DetailedSettingBoxProps = { user: AccountWithProfile | undefined; }; +import { useTranslation } from "react-i18next"; + const DetailSettingBox = ({ user }: DetailedSettingBoxProps) => { + const { t } = useTranslation(); const { form, handleSubmit } = useChangeProfile(user); const { form: passwordForm, handleChangePassword } = useChangePassword(); const isAdmin = user?.role === AccountRole.ADMIN; @@ -19,7 +22,7 @@ const DetailSettingBox = ({ user }: DetailedSettingBoxProps) => {

- Detailed Settings + {t("profile.sections.details")}

{!isAdmin && ( diff --git a/src/pages/profile/components/profile/GeneralInfoBox.tsx b/src/pages/profile/components/profile/GeneralInfoBox.tsx index fee3d2b..e918d44 100644 --- a/src/pages/profile/components/profile/GeneralInfoBox.tsx +++ b/src/pages/profile/components/profile/GeneralInfoBox.tsx @@ -7,12 +7,14 @@ import { NavLink } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ModeToggle } from "@/components/theme/ModeToggle"; import { Card, CardContent } from "@/components/ui/card"; +import { useTranslation } from "react-i18next"; type GeneralInfoBoxProps = { user: AccountWithProfile | undefined; handleLogout: () => Promise; }; const GeneralInfoBox = ({ user, handleLogout }: GeneralInfoBoxProps) => { + const { t } = useTranslation(); return ( @@ -23,14 +25,14 @@ const GeneralInfoBox = ({ user, handleLogout }: GeneralInfoBoxProps) => { /> {user && }
- + {user ? ( <>

- Email: {user.email} + {t("profile.labels.email")}: {user.email}

- Status: + {t("profile.labels.status")}: @@ -38,14 +40,14 @@ const GeneralInfoBox = ({ user, handleLogout }: GeneralInfoBoxProps) => { ) : (

- No data available + {t("profile.labels.no_data")}

)}
{/* PREFERENCES */} - +
- Modes: + {t("profile.labels.modes")}:
@@ -56,7 +58,7 @@ const GeneralInfoBox = ({ user, handleLogout }: GeneralInfoBoxProps) => { onClick={() => handleLogout()} > - Logout + {t("profile.actions.logout")} diff --git a/src/pages/profile/components/profile/Profile.tsx b/src/pages/profile/components/profile/Profile.tsx index ddd1f61..6f02e81 100644 --- a/src/pages/profile/components/profile/Profile.tsx +++ b/src/pages/profile/components/profile/Profile.tsx @@ -9,8 +9,10 @@ import InfoBox from "./InfoBox"; import DetailSettingBox from "./DetailedSettingBox"; import { useGetProfile } from "@/services/profile/queries"; import Loading from "@/components/Loading"; +import { useTranslation } from "react-i18next"; export default function Profile() { + const { t } = useTranslation(); const { handleLogout } = useAuth(); const { data: profile, isLoading } = useGetProfile(); const { height, width = 0 } = useWindowSize(); @@ -31,7 +33,7 @@ export default function Profile() { return (
- + {isMobile ? ( diff --git a/src/pages/profile/components/profile/ProfileForm.tsx b/src/pages/profile/components/profile/ProfileForm.tsx index fc64150..c290744 100644 --- a/src/pages/profile/components/profile/ProfileForm.tsx +++ b/src/pages/profile/components/profile/ProfileForm.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import type { AccountWithProfile } from "@/types/models/account"; import { AccountRole } from "@/types/enums/role"; import type { ChangeProfileFormData } from "./libs/schema"; +import { useTranslation } from "react-i18next"; type ProfileFormProps = { user: AccountWithProfile | undefined; @@ -24,6 +25,7 @@ export default function ProfileForm({ form, onSubmit, }: ProfileFormProps) { + const { t } = useTranslation(); const isCustomer = user?.role === AccountRole.CUSTOMER; return ( @@ -36,9 +38,9 @@ export default function ProfileForm({ name="firstName" render={({ field }) => ( - First Name + {t("profile.labels.first_name")} - + @@ -49,9 +51,9 @@ export default function ProfileForm({ name="lastName" render={({ field }) => ( - Last Name + {t("profile.labels.last_name")} - + @@ -64,7 +66,7 @@ export default function ProfileForm({ name="email" render={() => ( - Email + {t("profile.labels.email")} ( - Phone + {t("profile.labels.phone")} - + @@ -96,9 +98,9 @@ export default function ProfileForm({ name="address" render={({ field }) => ( - Address + {t("profile.labels.address")} - + @@ -112,7 +114,7 @@ export default function ProfileForm({ className="!bg-purple-primary !text-white dark:!text-black cursor-pointer" disabled={!form.formState.isDirty} > - Save Changes + {t("profile.actions.save_changes")}
diff --git a/src/pages/unauthorized/index.tsx b/src/pages/unauthorized/index.tsx index b10da00..7ca469e 100644 --- a/src/pages/unauthorized/index.tsx +++ b/src/pages/unauthorized/index.tsx @@ -1,7 +1,10 @@ +import { useTranslation } from "react-i18next"; + export default function Unauthorized() { + const { t } = useTranslation(); return (
- You don't have permission to access this page. + {t("errors.unauthorized")}
); } From 54605f57bb2a6f27b968c0bf8b0956521723100b Mon Sep 17 00:00:00 2001 From: lamtailoi2 Date: Sun, 5 Apr 2026 15:47:14 +0700 Subject: [PATCH 5/5] feat: update language settings position --- src/layout/index.tsx | 2 +- src/pages/notification/components/NotificationPopover.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 367742b..0b15d45 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -17,7 +17,7 @@ export default function MainLayout() { className="md:hidden fixed top-3 right-5 h-8 w-8 z-50" />
-
+
{!isNotificationPage && }
diff --git a/src/pages/notification/components/NotificationPopover.tsx b/src/pages/notification/components/NotificationPopover.tsx index 464ff8f..f1e0d43 100644 --- a/src/pages/notification/components/NotificationPopover.tsx +++ b/src/pages/notification/components/NotificationPopover.tsx @@ -134,8 +134,8 @@ export default function NotificationsPopover() { return ( - -
+ +
{unreadCount > 0 && (