feat:新增多语言

This commit is contained in:
JiaJun
2026-04-14 17:27:52 +08:00
parent 8fcf0355b3
commit b2c7c8d362
31 changed files with 1952 additions and 378 deletions

View File

@@ -1,8 +1,10 @@
import {useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useTranslation} from 'react-i18next'
import PageLayout from '@/components/layout'
import {ORDER_STATUS} from '@/constant'
import i18n from '@/lib/i18n'
import { cn } from '@/lib'
import Modal from '@/components/modal'
import Button from '@/components/button'
@@ -63,13 +65,13 @@ function getOrderStatus(status?: string | number) {
if (matchedStatus) {
switch (matchedStatus) {
case 'PENDING':
return 'Pending'
return i18n.t('record.statusLabel.pending')
case 'COMPLETED':
return 'Completed'
return i18n.t('record.statusLabel.completed')
case 'SHIPPED':
return 'Shipped'
return i18n.t('record.statusLabel.shipped')
case 'REJECTED':
return 'Rejected'
return i18n.t('record.statusLabel.rejected')
}
}
}
@@ -81,20 +83,20 @@ function getOrderStatus(status?: string | number) {
if (typeof normalizedStatus === 'number') {
switch (normalizedStatus) {
case 0:
return 'Pending'
return i18n.t('record.statusLabel.pending')
case 1:
return 'Completed'
return i18n.t('record.statusLabel.completed')
case 2:
return 'Shipped'
return i18n.t('record.statusLabel.shipped')
case 3:
return 'Rejected'
return i18n.t('record.statusLabel.rejected')
default:
return String(normalizedStatus)
}
}
if (!normalizedStatus) {
return 'Pending'
return i18n.t('record.statusLabel.pending')
}
return toTitleCase(normalizedStatus)
@@ -102,7 +104,17 @@ function getOrderStatus(status?: string | number) {
function getOrderCategory(item: OrderItem) {
if (item.type?.trim()) {
return item.type.trim().toUpperCase()
const normalizedType = item.type.trim().toUpperCase()
switch (normalizedType) {
case 'BONUS':
return i18n.t('record.categories.bonus')
case 'PHYSICAL':
return i18n.t('record.categories.physical')
case 'WITHDRAW':
return i18n.t('record.categories.withdraw')
default:
return toTitleCase(normalizedType)
}
}
if (item.type_title) {
@@ -116,11 +128,11 @@ function getOrderCategory(item: OrderItem) {
if (item.type) {
switch (item.type) {
case 'BONUS':
return 'Bonus'
return i18n.t('record.categories.bonus')
case 'PHYSICAL':
return 'Physical'
return i18n.t('record.categories.physical')
case 'WITHDRAW':
return 'Transfer to Platform'
return i18n.t('record.categories.withdraw')
default:
return toTitleCase(item.type)
}
@@ -130,7 +142,7 @@ function getOrderCategory(item: OrderItem) {
return toTitleCase(item.category)
}
return 'Order'
return i18n.t('record.categories.order')
}
function getOrderPoints(item: OrderItem) {
@@ -143,11 +155,11 @@ function getOrderPoints(item: OrderItem) {
const numericValue = typeof rawValue === 'string' ? Number(rawValue) : rawValue
if (Number.isNaN(numericValue)) {
const textValue = String(rawValue)
return {display: `${textValue} points`}
return {display: `${textValue} ${i18n.t('common.points')}`}
}
return {
display: `${numericValue} points`,
display: `${numericValue} ${i18n.t('common.points')}`,
}
}
@@ -176,7 +188,7 @@ function mapOrderItemToRecord(item: OrderItem): OrderRecord {
date,
time,
category: getOrderCategory(item),
title: item.item_title ?? item.title ?? item.mallItem?.title ?? 'Untitled Order',
title: item.item_title ?? item.title ?? item.mallItem?.title ?? i18n.t('record.untitledOrder'),
trackingNumber: getTrackingNumber(item),
status: getOrderStatus(item.status),
points: points.display,
@@ -221,7 +233,7 @@ function getPointsRecordTitle(item: PointsLogItem) {
item.type,
item.description,
item.remark?.split('\n')[0],
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? 'Points Record'
].find((value) => typeof value === 'string' && value.trim())?.trim() ?? i18n.t('record.pointsRecordFallback')
}
function mapPointsLogItemToRecord(item: PointsLogItem) {
@@ -239,18 +251,24 @@ function mapPointsLogItemToRecord(item: PointsLogItem) {
}
function getOrderStatusClassName(status: string) {
switch (status.toLowerCase()) {
case 'completed':
return 'bg-[#9BFFC0] text-[#176640]'
case 'shipped':
return 'bg-[#95F0FF] text-[#116A79]'
case 'pending':
return 'bg-[#FFF18C] text-[#7F6A0D]'
case 'rejected':
return 'bg-[#FFB1C0] text-[#7C2941]'
default:
return 'bg-white/15 text-white/80'
const normalizedStatus = status.toLowerCase()
if (normalizedStatus === 'completed' || status === i18n.t('record.statusLabel.completed')) {
return 'bg-[#9BFFC0] text-[#176640]'
}
if (normalizedStatus === 'shipped' || status === i18n.t('record.statusLabel.shipped')) {
return 'bg-[#95F0FF] text-[#116A79]'
}
if (normalizedStatus === 'pending' || status === i18n.t('record.statusLabel.pending')) {
return 'bg-[#FFF18C] text-[#7F6A0D]'
}
if (normalizedStatus === 'rejected' || status === i18n.t('record.statusLabel.rejected')) {
return 'bg-[#FFB1C0] text-[#7C2941]'
}
return 'bg-white/15 text-white/80'
}
function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
@@ -272,8 +290,9 @@ function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
}
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
const {t} = useTranslation()
return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white">
{record.date} {record.time} {record.category}
</div>
@@ -282,7 +301,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
<div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45">
Tracking Number&nbsp;&nbsp;{record.trackingNumber}
{t('record.trackingNumber')}&nbsp;&nbsp;{record.trackingNumber}
</div>
) : null}
<button
@@ -290,7 +309,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
className="mt-[10px] inline-flex items-center gap-[5px] text-[13px] text-[#FA6A00] focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => onOpenDetails(record)}
>
Check the details
{t('record.checkDetails')}
<ChevronRight className="h-[14px] w-[14px]" aria-hidden="true" />
</button>
</div>
@@ -313,7 +332,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
function PointsCard({ record }: PointsCardProps) {
return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="shrink-0 overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[15px] text-white">
{record.title}
</div>
@@ -341,6 +360,7 @@ function OrdersTabContent({
sessionId: string
onOpenDetails: (record: OrderRecord) => void
}) {
const {t} = useTranslation()
const ordersQuery = useQuery({
queryKey: queryKeys.orders(sessionId),
enabled: Boolean(sessionId),
@@ -358,7 +378,7 @@ function OrdersTabContent({
if (ordersQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
{t('record.loading')}
</div>
)
}
@@ -366,21 +386,22 @@ function OrdersTabContent({
if (!orderRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
{t('record.noData')}
</div>
)
}
return (
<>
<div className="space-y-[12px] pb-[4px]">
{orderRecords.map((record) => (
<OrderCard key={record.id} record={record} onOpenDetails={onOpenDetails} />
))}
</>
</div>
)
}
function PointsTabContent({sessionId}: {sessionId: string}) {
const {t} = useTranslation()
const pointsLogsQuery = useQuery({
queryKey: queryKeys.pointsLogs(sessionId),
enabled: Boolean(sessionId),
@@ -398,7 +419,7 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
if (pointsLogsQuery.isPending) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
Loading...
{t('record.loading')}
</div>
)
}
@@ -406,19 +427,20 @@ function PointsTabContent({sessionId}: {sessionId: string}) {
if (!pointsRecords.length) {
return (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No Data
{t('record.noData')}
</div>
)
}
return (
<>
<div className="space-y-[12px] pb-[4px]">
{pointsRecords.map((record) => <PointsCard key={record.id} record={record} />)}
</>
</div>
)
}
function RecordPage() {
const {t} = useTranslation()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [tab, setTab] = useState<RecordButtonType>('order')
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null)
@@ -428,78 +450,84 @@ function RecordPage() {
}
return (
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[980px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">Back</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">Record</div>
<div className="w-[52px]"></div>
</Link>
<div className="mx-auto w-full max-w-[860px] pt-[18px] pb-[24px]">
<div className="mb-[12px] flex justify-end">
<div className="flex gap-[8px]">
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label="My Orders"
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label="Points Record"
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
<PageLayout contentClassName="flex h-[100svh] w-full flex-col overflow-hidden px-4 pb-8 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-[980px]">
<Link
to="/"
className="mt-[12px] flex h-[44px] items-center justify-between rounded-[12px] bg-[#08070E]/72 px-[14px] text-[#F56E10] transition-colors hover:bg-[#0D0A14]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E] sm:mt-[16px]"
>
<div className="flex items-center gap-[8px]">
<ArrowLeft className="h-[16px] w-[16px]" aria-hidden="true" />
<span className="text-[14px] font-medium text-white/92">{t('common.back')}</span>
</div>
<div className="text-[15px] font-semibold text-[#F56E10]">{t('record.title')}</div>
<div className="w-[52px]"></div>
</Link>
</div>
<div className="flex min-h-0 flex-1 flex-col pt-[18px] pb-[24px]">
<div className="mx-auto w-full max-w-[860px]">
<div className="mb-[12px] flex justify-end">
<div className="flex gap-[8px]">
<TabButton
active={tab === 'order'}
icon={PackageSearch}
label={t('record.myOrders')}
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label={t('record.pointsRecord')}
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
</div>
</div>
<div className="h-px bg-white/16"></div>
</div>
<div className="h-px bg-white/16"></div>
<div className="mt-[14px] flex flex-col gap-[12px]">
{tab === 'order' ? (
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
<div className="mt-[14px] min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[860px]">
{tab === 'order' ? (
<OrdersTabContent key="order" sessionId={sessionId} onOpenDetails={setSelectedOrder} />
) : (
<PointsTabContent key="record" sessionId={sessionId} />
)}
</div>
</div>
</div>
<Modal
open={Boolean(selectedOrder)}
title="Order Details"
title={t('record.orderDetails')}
onClose={handleCloseDetails}
className="max-w-[420px]"
bodyClassName="pt-[0px]"
footer={
<Button type="button" className="h-[36px] w-full sm:min-w-[94px] sm:w-auto" onClick={handleCloseDetails}>
Close
{t('common.close')}
</Button>
}
>
{selectedOrder ? (
<div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
{[
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: 'Order Type', value: selectedOrder.category },
{ label: 'Item Name', value: selectedOrder.title },
{ label: t('record.orderNumber'), value: selectedOrder.orderNumber ?? '--' },
{ label: t('record.orderTime'), value: `${selectedOrder.date} ${selectedOrder.time}` },
{ label: t('record.orderType'), value: selectedOrder.category },
{ label: t('record.itemName'), value: selectedOrder.title },
...(selectedOrder.trackingNumber
? [{ label: 'Tracking Number', value: selectedOrder.trackingNumber }]
? [{ label: t('record.trackingNumber'), value: selectedOrder.trackingNumber }]
: []),
{ label: 'Points', value: selectedOrder.points.replace(/^-/, '') },
{ label: 'Status', value: selectedOrder.status },
{ label: t('record.points'), value: selectedOrder.points.replace(/^-/, '') },
{ label: t('record.status'), value: selectedOrder.status },
].map((item) => (
<div key={item.label} className="border-b border-white/8 py-[10px] last:border-b-0">
<div className="text-[13px] text-white/48">{item.label}</div>