feat(i18n): 添加马来语支持并重构多语言功能
This commit is contained in:
@@ -27,7 +27,7 @@ function GoodsImage({
|
||||
const showFallback = !imageUrl || hasError
|
||||
|
||||
return (
|
||||
<div className="relative h-[116px] w-full overflow-hidden rounded-t-[10px] sm:h-[128px]">
|
||||
<div className="relative w-full overflow-hidden rounded-t-[10px] bg-white/6 aspect-[3/2] min-h-[144px] max-h-[196px] sm:min-h-[156px] sm:max-h-[208px]">
|
||||
{showFallback ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_top,rgba(250,106,0,0.18),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-[12px] text-center">
|
||||
<div className="h-[30px] w-[30px] rounded-[10px] border border-white/10 bg-white/6"></div>
|
||||
@@ -86,10 +86,10 @@ export function GoodsCategoryList({
|
||||
{Array.from({length: 4}).map((_, index) => (
|
||||
<div
|
||||
key={`${categoryId}-${index}`}
|
||||
className="liquid-glass-bg flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
|
||||
className="liquid-glass-bg flex min-h-[296px] w-full flex-col items-stretch justify-start overflow-hidden sm:min-h-[312px]"
|
||||
>
|
||||
<div className="h-[116px] w-full bg-white/6 sm:h-[128px]"></div>
|
||||
<div className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]">
|
||||
<div className="aspect-[3/2] min-h-[144px] max-h-[196px] w-full bg-white/6 sm:min-h-[156px] sm:max-h-[208px]"></div>
|
||||
<div className="flex flex-1 flex-col items-start justify-between gap-[14px] p-[14px] sm:p-[16px]">
|
||||
<div className="w-full space-y-[8px]">
|
||||
<div className="h-[18px] w-[72%] rounded-full bg-white/10"></div>
|
||||
<div className="h-[14px] w-[48%] rounded-full bg-white/8"></div>
|
||||
@@ -148,13 +148,13 @@ export function GoodsCategoryList({
|
||||
{category.items.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="liquid-glass-bg pc-hover-float flex min-h-[260px] w-full flex-col items-stretch justify-start overflow-hidden"
|
||||
className="liquid-glass-bg pc-hover-float flex min-h-[296px] w-full flex-col items-stretch justify-start overflow-hidden sm:min-h-[312px]"
|
||||
>
|
||||
<GoodsImage imageUrl={product.imageUrl}/>
|
||||
<div
|
||||
className="flex flex-1 flex-col items-start justify-between gap-[12px] p-[12px] sm:p-[14px]"
|
||||
className="flex flex-1 flex-col items-start justify-between gap-[14px] p-[14px] sm:p-[16px]"
|
||||
>
|
||||
<div className="space-y-[4px]">
|
||||
<div className="w-full space-y-[4px]">
|
||||
<div className="text-[16px] font-medium text-white">{product.title}</div>
|
||||
<div className="text-[13px] text-white/52">{product.subtitle}</div>
|
||||
</div>
|
||||
|
||||
@@ -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<AppLanguage>
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
157
src/locale/ms.ts
Normal file
157
src/locale/ms.ts
Normal file
@@ -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
|
||||
@@ -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: '待领取积分',
|
||||
@@ -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() {
|
||||
<MotionButton
|
||||
type="button"
|
||||
className="liquid-glass-bg flex items-center justify-between gap-[12px] rounded-[12px] px-[12px] py-[8px] text-[13px] text-white/88 transition-colors hover:bg-white/28 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
|
||||
onClick={handleToggleLanguage}
|
||||
onClick={() => setLanguageModalOpen(true)}
|
||||
{...tapMotionProps}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-[10px]">
|
||||
@@ -159,7 +167,7 @@ function HomePage() {
|
||||
<Languages className="h-[16px] w-[16px] shrink-0" aria-hidden="true"/>
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-medium">{normalizeLanguage(language) === 'zh' ? t('nav.switchToEnglish') : t('nav.switchToChinese')}</div>
|
||||
className="truncate font-medium">{t(`nav.${LANGUAGE_OPTIONS.find((option) => option.value === currentLanguage)?.labelKey ?? 'switchToChinese'}`)}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-[16px] w-[16px] shrink-0 text-white/70" aria-hidden="true"/>
|
||||
</MotionButton>
|
||||
@@ -303,6 +311,27 @@ function HomePage() {
|
||||
onChangeAddressForm={redeem.changeAddressForm}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={languageModalOpen}
|
||||
title={t('common.language')}
|
||||
onClose={() => setLanguageModalOpen(false)}
|
||||
className="max-w-[420px]"
|
||||
bodyClassName="space-y-3"
|
||||
>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={option.value === currentLanguage ? 'orange' : 'gray'}
|
||||
className="flex h-[46px] w-full items-center justify-between px-[14px] text-[14px]"
|
||||
onClick={() => handleSelectLanguage(option.value)}
|
||||
>
|
||||
<span>{t(`nav.${option.labelKey}`)}</span>
|
||||
{option.value === currentLanguage ? <Check className="h-[16px] w-[16px] shrink-0"/> : null}
|
||||
</Button>
|
||||
))}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={claimModalOpen}
|
||||
title={t('home.confirmClaim')}
|
||||
|
||||
Reference in New Issue
Block a user