From 236ae4b37edc2d9607e9760a3c91c8cf3f258bfc Mon Sep 17 00:00:00 2001
From: JiaJun <2394389886@qq.com>
Date: Tue, 21 Apr 2026 14:25:50 +0800
Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=E6=B7=BB=E5=8A=A0=E9=A9=AC?=
=?UTF-8?q?=E6=9D=A5=E8=AF=AD=E6=94=AF=E6=8C=81=E5=B9=B6=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/goods/GoodsCategoryList.tsx | 14 +-
src/lib/i18n.ts | 26 +++-
src/lib/request.ts | 4 +-
src/{message => locale}/en.ts | 6 +-
src/locale/ms.ts | 157 +++++++++++++++++++++++
src/{message => locale}/zh.ts | 4 +-
src/views/home/index.tsx | 43 ++++++-
7 files changed, 231 insertions(+), 23 deletions(-)
rename src/{message => locale}/en.ts (97%)
create mode 100644 src/locale/ms.ts
rename src/{message => locale}/zh.ts (98%)
diff --git a/src/features/goods/GoodsCategoryList.tsx b/src/features/goods/GoodsCategoryList.tsx
index f9921a3..2526bb2 100644
--- a/src/features/goods/GoodsCategoryList.tsx
+++ b/src/features/goods/GoodsCategoryList.tsx
@@ -27,7 +27,7 @@ function GoodsImage({
const showFallback = !imageUrl || hasError
return (
-
+
{showFallback ? (
@@ -86,10 +86,10 @@ export function GoodsCategoryList({
{Array.from({length: 4}).map((_, index) => (
-
-
+
+
@@ -148,13 +148,13 @@ export function GoodsCategoryList({
{category.items.map((product) => (
-
+
{product.title}
{product.subtitle}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 05b7e0d..615593c 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -1,18 +1,35 @@
import i18n from 'i18next'
import {initReactI18next} from 'react-i18next'
-import en from '@/message/en'
-import zh from '@/message/zh'
+import en from '@/locale/en'
+import ms from '@/locale/ms'
+import zh from '@/locale/zh'
import {useUserStore} from '@/store/user.ts'
-export function normalizeLanguage(language?: string) {
+export const SUPPORTED_LANGUAGES = ['zh', 'en', 'ms'] as const
+
+export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
+
+export function normalizeLanguage(language?: string): AppLanguage {
const value = language?.trim().toLowerCase()
if (!value) {
return 'zh'
}
- return value.startsWith('zh') ? 'zh' : 'en'
+ if (value.startsWith('zh')) {
+ return 'zh'
+ }
+
+ if (value.startsWith('ms')) {
+ return 'ms'
+ }
+
+ return 'en'
+}
+
+export function getRequestLanguageKey(language?: string) {
+ return normalizeLanguage(language).toUpperCase() as Uppercase
}
const initialLanguage = normalizeLanguage(useUserStore.getState().language)
@@ -23,6 +40,7 @@ void i18n
resources: {
zh: {translation: zh},
en: {translation: en},
+ ms: {translation: ms},
},
lng: initialLanguage,
fallbackLng: 'zh',
diff --git a/src/lib/request.ts b/src/lib/request.ts
index 130ad7f..15977e2 100644
--- a/src/lib/request.ts
+++ b/src/lib/request.ts
@@ -2,7 +2,7 @@ import ky, {HTTPError, type AfterResponseHook, type Input, type KyInstance, type
import {notifyError, resolveToastMessage} from '@/features/notifications'
import i18n from '@/lib/i18n'
-import {normalizeLanguage} from '@/lib/i18n'
+import {getRequestLanguageKey} from '@/lib/i18n'
import {useUserStore} from '@/store/user.ts'
import type {ValidateTokenData} from '@/types/auth.type.ts'
import {objectToFormData} from './tool'
@@ -68,7 +68,7 @@ export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
-const getRequestLanguage = () => normalizeLanguage(useUserStore.getState().language)
+const getRequestLanguage = () => getRequestLanguageKey(useUserStore.getState().language)
const authRefreshClient = ky.create({
baseUrl: API_BASE_URL,
diff --git a/src/message/en.ts b/src/locale/en.ts
similarity index 97%
rename from src/message/en.ts
rename to src/locale/en.ts
index 4856b42..fb4ba08 100644
--- a/src/message/en.ts
+++ b/src/locale/en.ts
@@ -13,6 +13,7 @@ const en = {
language: 'Language',
chinese: 'Chinese',
english: 'English',
+ malay: 'Malay',
},
auth: {
waitingForHostContext: 'Waiting for host context...',
@@ -23,8 +24,9 @@ const en = {
nav: {
record: 'Record',
account: 'Account',
- switchToChinese: '中文',
- switchToEnglish: 'EN',
+ switchToChinese: 'Chinese',
+ switchToEnglish: 'English',
+ switchToMalay: 'Malay',
},
home: {
claimablePoints: 'Claimable Points',
diff --git a/src/locale/ms.ts b/src/locale/ms.ts
new file mode 100644
index 0000000..3313517
--- /dev/null
+++ b/src/locale/ms.ts
@@ -0,0 +1,157 @@
+const ms = {
+ common: {
+ back: 'Kembali',
+ loading: 'Memuatkan...',
+ loadingPage: 'Sedang memuatkan halaman...',
+ processing: 'Sedang diproses...',
+ cancel: 'Batal',
+ confirm: 'Sahkan',
+ close: 'Tutup',
+ noData: 'Tiada data',
+ points: 'Mata',
+ more: 'Lagi',
+ language: 'Bahasa',
+ chinese: 'Bahasa Cina',
+ english: 'Bahasa Inggeris',
+ malay: 'Bahasa Melayu',
+ },
+ auth: {
+ waitingForHostContext: 'Menunggu konteks hos...',
+ loadingAccountData: 'Sedang memuatkan data akaun...',
+ authenticationFailed: 'Pengesahan gagal',
+ refreshAndTryAgain: 'Sila muat semula dan cuba lagi.',
+ },
+ nav: {
+ record: 'Rekod',
+ account: 'Akaun',
+ switchToChinese: 'Cina',
+ switchToEnglish: 'Inggeris',
+ switchToMalay: 'Melayu',
+ },
+ home: {
+ claimablePoints: 'Mata Boleh Dituntut',
+ claimDescription: 'Kerugian semalam telah ditukar kepada mata. Tuntut untuk digunakan bagi penebusan atau pengeluaran.',
+ dailyClaimLimit: 'Had Tuntutan Harian',
+ claimed: 'Telah Dituntut',
+ availableForWithdrawal: 'Tersedia untuk Pengeluaran (Tunai)',
+ availablePoints: 'Mata Tersedia',
+ cashUnit: 'RM',
+ claimNow: 'Tuntut Sekarang',
+ syncBalance: 'Segerak Baki',
+ syncing: 'Sedang disegerakkan...',
+ confirmClaim: 'Sahkan Tuntutan',
+ confirmClaimDescription: 'Selepas mata yang belum dituntut ditukar menjadi mata yang boleh digunakan, ia boleh digunakan untuk penebusan atau pengeluaran. Adakah anda pasti mahu menuntut?',
+ balanceSyncedSuccessfully: 'Baki berjaya disegerakkan.',
+ claimSubmittedSuccessfully: 'Tuntutan berjaya dihantar.',
+ noClaimablePointsAvailable: 'Tiada mata yang boleh dituntut.',
+ },
+ goods: {
+ categories: {
+ WITHDRAW: 'Pindah ke Platform',
+ BONUS: 'Bonus Permainan',
+ PHYSICAL: 'Hadiah Fizikal',
+ },
+ actions: {
+ WITHDRAW: 'Pindah Sekarang',
+ BONUS: 'Tebus Bonus',
+ PHYSICAL: 'Tuntut Hadiah',
+ },
+ noImage: 'TIADA GAMBAR',
+ loading: 'Memuatkan...',
+ noGoodsAvailableYet: 'Tiada barangan untuk ditebus buat masa ini.',
+ noGoodsForCategory: 'Tiada barangan dalam kategori ini.',
+ confirmWithdrawal: 'Sahkan Pengeluaran',
+ confirmBonusRedemption: 'Sahkan Penebusan Bonus',
+ confirmPhysicalReward: 'Sahkan Hadiah Fizikal',
+ addShippingAddress: 'Tambah Alamat Penghantaran',
+ editShippingAddress: 'Edit Alamat Penghantaran',
+ saveChanges: 'Simpan Perubahan',
+ addressInfo: 'Maklumat Alamat',
+ defaultAddress: 'Alamat Lalai',
+ name: 'Nama',
+ phoneNumber: 'Nombor Telefon',
+ detailedAddress: 'Alamat Terperinci',
+ enterReceiverName: 'Masukkan nama penuh penerima',
+ enterReachablePhone: 'Masukkan nombor telefon yang boleh dihubungi',
+ enterDetailAddress: 'Masukkan alamat terperinci',
+ withdrawalAmount: 'Jumlah Pengeluaran',
+ pointsRequired: 'Mata Diperlukan',
+ turnoverRequirement: 'Syarat Pusingan',
+ submitWithdrawalRequest: 'Hantar permohonan pengeluaran?',
+ item: 'Barangan',
+ pointsCost: 'Kos Mata',
+ pleaseSelectAddressInfo: 'Sila pilih dan isi maklumat alamat.',
+ loadingAddressList: 'Sedang memuatkan senarai alamat...',
+ addAddress: 'Tambah Alamat',
+ selectShippingAddress: 'Sila pilih alamat penghantaran.',
+ addressAddedSuccessfully: 'Alamat berjaya ditambah.',
+ redeemRequestSubmittedSuccessfully: 'Permohonan penebusan berjaya dihantar.',
+ bonusRedeemSubmittedSuccessfully: 'Permohonan penebusan bonus berjaya dihantar.',
+ physicalPrize: 'Hadiah Fizikal',
+ },
+ account: {
+ title: 'Akaun',
+ myShippingAddress: 'Alamat Penghantaran Saya',
+ addAddress: 'Tambah Alamat',
+ loadingAddressList: 'Sedang memuatkan senarai alamat...',
+ noShippingAddressFound: 'Tiada alamat penghantaran ditemui. Sila tambah dahulu.',
+ address: 'Alamat',
+ edit: 'Edit',
+ delete: 'Padam',
+ default: 'Lalai',
+ optional: 'Bukan Lalai',
+ addressUpdatedSuccessfully: 'Alamat berjaya dikemas kini.',
+ addressDeletedSuccessfully: 'Alamat berjaya dipadam.',
+ deleteAddress: 'Padam Alamat',
+ deleteAddressFor: 'Padam alamat untuk {{name}}?',
+ },
+ record: {
+ title: 'Rekod',
+ myOrders: 'Pesanan Saya',
+ pointsRecord: 'Rekod Mata',
+ loading: 'Memuatkan...',
+ noData: 'Tiada data',
+ checkDetails: 'Lihat butiran',
+ trackingNumber: 'Nombor Penjejakan',
+ orderDetails: 'Butiran Pesanan',
+ orderNumber: 'Nombor Pesanan',
+ orderTime: 'Masa Pesanan',
+ orderType: 'Jenis Pesanan',
+ itemName: 'Nama Barangan',
+ points: 'Mata',
+ status: 'Status',
+ untitledOrder: 'Pesanan Tanpa Tajuk',
+ pointsRecordFallback: 'Rekod Mata',
+ categories: {
+ bonus: 'Bonus Permainan',
+ physical: 'Hadiah Fizikal',
+ withdraw: 'Pindah ke Platform',
+ order: 'Pesanan',
+ },
+ statusLabel: {
+ pending: 'Menunggu',
+ completed: 'Selesai',
+ shipped: 'Dihantar',
+ rejected: 'Ditolak',
+ },
+ },
+ validation: {
+ sessionExpired: 'Sesi telah tamat. Sila log masuk semula.',
+ noProductSelected: 'Tiada barangan dipilih.',
+ pleaseSelectShippingAddress: 'Sila pilih alamat penghantaran.',
+ pleaseCompleteAddressFields: 'Sila lengkapkan semua maklumat alamat.',
+ pleaseEnterReceiverName: 'Sila masukkan nama penerima.',
+ pleaseEnterReachablePhone: 'Sila masukkan nombor telefon yang boleh dihubungi.',
+ pleaseEnterDetailedAddress: 'Sila masukkan alamat terperinci.',
+ },
+ errors: {
+ unauthorized: 'Tidak dibenarkan',
+ requestFailed: 'Permintaan gagal',
+ networkRequestFailed: 'Permintaan rangkaian gagal',
+ },
+ app: {
+ loading: 'Memuatkan...',
+ },
+}
+
+export default ms
diff --git a/src/message/zh.ts b/src/locale/zh.ts
similarity index 98%
rename from src/message/zh.ts
rename to src/locale/zh.ts
index 7116572..4666ff1 100644
--- a/src/message/zh.ts
+++ b/src/locale/zh.ts
@@ -13,6 +13,7 @@ const zh = {
language: '语言',
chinese: '中文',
english: '英文',
+ malay: '马来文',
},
auth: {
waitingForHostContext: '等待宿主上下文...',
@@ -24,7 +25,8 @@ const zh = {
record: '记录',
account: '我的',
switchToChinese: '中文',
- switchToEnglish: 'EN',
+ switchToEnglish: '英文',
+ switchToMalay: '马来文',
},
home: {
claimablePoints: '待领取积分',
diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx
index 2d524ea..50bfe6a 100644
--- a/src/views/home/index.tsx
+++ b/src/views/home/index.tsx
@@ -1,7 +1,7 @@
import {useState} from 'react'
import {useMutation} from '@tanstack/react-query'
-import {Languages} from 'lucide-react'
+import {Check, Languages} from 'lucide-react'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
@@ -33,7 +33,13 @@ import {claim} from '@/api/business.ts'
import {notifyError, notifySuccess} from '@/features/notifications'
import {MotionButton, MotionLink, tapMotionProps} from '@/lib/motion'
import {useUserStore} from "@/store/user.ts";
-import {normalizeLanguage} from '@/lib/i18n'
+import {type AppLanguage, normalizeLanguage} from '@/lib/i18n'
+
+const LANGUAGE_OPTIONS: Array<{value: AppLanguage; labelKey: 'switchToChinese' | 'switchToEnglish' | 'switchToMalay'}> = [
+ {value: 'zh', labelKey: 'switchToChinese'},
+ {value: 'en', labelKey: 'switchToEnglish'},
+ {value: 'ms', labelKey: 'switchToMalay'},
+]
function QuickNavCard({icon: Icon, label, to}: QuickNavCardProps) {
return (
@@ -65,6 +71,7 @@ function getProgressPercent(current = 0, total = 0) {
function HomePage() {
const {t} = useTranslation()
const [claimModalOpen, setClaimModalOpen] = useState(false)
+ const [languageModalOpen, setLanguageModalOpen] = useState(false)
const navigate = useNavigate()
const authInfo = useUserStore(state => state.authInfo)
const language = useUserStore((state) => state.language)
@@ -87,6 +94,7 @@ function HomePage() {
const {assetsInfo} = useAssetsQuery()
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
const isClaimAvailable = (assetsInfo?.locked_points ?? 0) > 0
+ const currentLanguage = normalizeLanguage(language)
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
...category,
items: category.items.slice(0, 4),
@@ -136,9 +144,9 @@ function HomePage() {
navigate(`/goods?type=${type}`)
}
- const handleToggleLanguage = () => {
- const currentLanguage = normalizeLanguage(language)
- setLanguage(currentLanguage === 'zh' ? 'en' : 'zh')
+ const handleSelectLanguage = (nextLanguage: AppLanguage) => {
+ setLanguage(nextLanguage)
+ setLanguageModalOpen(false)
}
return (
@@ -150,7 +158,7 @@ function HomePage() {
setLanguageModalOpen(true)}
{...tapMotionProps}
>
@@ -159,7 +167,7 @@ function HomePage() {
{normalizeLanguage(language) === 'zh' ? t('nav.switchToEnglish') : t('nav.switchToChinese')}
+ className="truncate font-medium">{t(`nav.${LANGUAGE_OPTIONS.find((option) => option.value === currentLanguage)?.labelKey ?? 'switchToChinese'}`)}
@@ -303,6 +311,27 @@ function HomePage() {
onChangeAddressForm={redeem.changeAddressForm}
/>
+ setLanguageModalOpen(false)}
+ className="max-w-[420px]"
+ bodyClassName="space-y-3"
+ >
+ {LANGUAGE_OPTIONS.map((option) => (
+
+ ))}
+
+