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'}`)}