+ className="flex items-center justify-between gap-[10px] border-b border-white/10 px-[6px] pb-[16px]">
Address Info
@@ -140,7 +140,7 @@ export function GoodsRedeemModal({
type="number"
onChange={(event) => onChangeAddressForm('phone', event.target.value)}
placeholder="Enter a reachable mobile number"
- className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
+ className="input-no-spin bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
onChangeAddressForm('detailedAddress', event.target.value)}
- placeholder="Street, building, unit and room number"
+ placeholder="Enter detail address"
className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/>
diff --git a/src/features/goods/RegionPicker.tsx b/src/features/goods/RegionPicker.tsx
index 81d7a0a..b317295 100644
--- a/src/features/goods/RegionPicker.tsx
+++ b/src/features/goods/RegionPicker.tsx
@@ -1,4 +1,5 @@
import {useEffect, useState} from 'react'
+import {ChevronDown} from 'lucide-react'
type ProvinceNode = {
c: string
@@ -32,6 +33,8 @@ type RegionDataset = {
area: AreaNode[]
}
+const DIRECT_MUNICIPALITY_NAMES = new Set(['北京市', '上海市', '天津市', '重庆市'])
+
function RegionSelect({
value,
placeholder,
@@ -60,8 +63,8 @@ function RegionSelect({
))}
-
)
@@ -70,7 +73,8 @@ function RegionSelect({
export function RegionPicker({value, onChange}: RegionPickerProps) {
const [regionData, setRegionData] = useState
(null)
const [loadFailed, setLoadFailed] = useState(false)
- const [province = '', city = '', district = ''] = value
+ const normalizedValue = value.filter(Boolean)
+ const province = normalizedValue[0] ?? ''
useEffect(() => {
let cancelled = false
@@ -120,18 +124,24 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
const areaList = regionData?.area ?? []
const selectedProvince = provinceList.find((item) => item.n === province)
- const selectedCity = cityList.find((item) => item.n === city && item.p === selectedProvince?.p)
-
const cities = selectedProvince
? cityList.filter((item) => item.p === selectedProvince.p)
: []
+ const isDirectMunicipality = province ? DIRECT_MUNICIPALITY_NAMES.has(province) || (selectedProvince != null && cities.length === 0) : false
+ const city = isDirectMunicipality ? '' : normalizedValue[1] ?? ''
+ const district = isDirectMunicipality ? normalizedValue[1] ?? '' : normalizedValue[2] ?? ''
+ const selectedCity = cityList.find((item) => item.n === city && item.p === selectedProvince?.p)
- const districts = selectedCity
- ? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
- : []
+ const districts = isDirectMunicipality
+ ? selectedProvince
+ ? areaList.filter((item) => item.p === selectedProvince.p)
+ : []
+ : selectedCity
+ ? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
+ : []
return (
-
+
onChange(nextProvince ? [nextProvince] : [])}
disabled={loadFailed || !regionData}
/>
- onChange(nextCity ? [province, nextCity] : [province])}
- />
- onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
- />
+ {isDirectMunicipality ? (
+ onChange(nextDistrict ? [province, nextDistrict] : [province])}
+ />
+ ) : (
+ onChange(nextCity ? [province, nextCity] : [province])}
+ />
+ )}
+ {isDirectMunicipality ? null : (
+ onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
+ />
+ )}
)
}
diff --git a/src/features/goods/useAssetsQuery.ts b/src/features/goods/useAssetsQuery.ts
index ab3cdb2..518357c 100644
--- a/src/features/goods/useAssetsQuery.ts
+++ b/src/features/goods/useAssetsQuery.ts
@@ -33,7 +33,5 @@ export function useAssetsQuery() {
return {
assetsInfo: assetsQuery.data ?? null,
- assetsLoading: assetsQuery.isPending,
- assetsError: assetsQuery.error instanceof Error ? assetsQuery.error.message : null,
}
}
diff --git a/src/features/goods/useGoodsCatalog.ts b/src/features/goods/useGoodsCatalog.ts
index de86d05..607b5d3 100644
--- a/src/features/goods/useGoodsCatalog.ts
+++ b/src/features/goods/useGoodsCatalog.ts
@@ -1,4 +1,4 @@
-import {useQuery, useQueryClient} from '@tanstack/react-query'
+import {useQuery} from '@tanstack/react-query'
import {goodList} from '@/api/business.ts'
import {
@@ -52,7 +52,6 @@ type UseGoodsCatalogOptions = {
}
export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
- const queryClient = useQueryClient()
const types = options?.types?.length ? [...options.types] : HOME_GOOD_TYPE_ORDER
const goodsCatalogQuery = useQuery({
queryKey: queryKeys.goodsCatalog(types),
@@ -83,7 +82,5 @@ export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
return {
productCategories: goodsCatalogQuery.data ?? [],
loading: goodsCatalogQuery.isPending,
- error: goodsCatalogQuery.error instanceof Error ? goodsCatalogQuery.error.message : null,
- invalidateGoods: () => queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
}
}
diff --git a/src/features/notifications/GlobalToast.tsx b/src/features/notifications/GlobalToast.tsx
index 8f81386..939d15b 100644
--- a/src/features/notifications/GlobalToast.tsx
+++ b/src/features/notifications/GlobalToast.tsx
@@ -5,16 +5,24 @@ import {useToastStore} from './store'
export function GlobalToast() {
const toast = useToastStore((state) => state.toast)
- if (!toast?.visible) {
+ if (!toast) {
return null
}
const isError = toast.type === 'error'
return (
-
+
| null = null
+let toastRemoveTimer: ReturnType
| null = null
export function resolveToastMessage(source: unknown, fallback = '') {
if (typeof source === 'string' && source.trim()) {
@@ -37,18 +38,44 @@ export const useToastStore = create((set) => ({
if (toastTimer) {
clearTimeout(toastTimer)
}
+ if (toastRemoveTimer) {
+ clearTimeout(toastRemoveTimer)
+ }
set({
toast: {
message,
type,
- visible: true,
+ visible: false,
},
})
+ requestAnimationFrame(() => {
+ set((state) => state.toast
+ ? {
+ toast: {
+ ...state.toast,
+ visible: true,
+ },
+ }
+ : state)
+ })
+
toastTimer = setTimeout(() => {
- set({toast: null})
+ set((state) => state.toast
+ ? {
+ toast: {
+ ...state.toast,
+ visible: false,
+ },
+ }
+ : state)
toastTimer = null
+
+ toastRemoveTimer = setTimeout(() => {
+ set({toast: null})
+ toastRemoveTimer = null
+ }, 220)
}, 2000)
},
clearToast: () => {
@@ -56,6 +83,10 @@ export const useToastStore = create((set) => ({
clearTimeout(toastTimer)
toastTimer = null
}
+ if (toastRemoveTimer) {
+ clearTimeout(toastRemoveTimer)
+ toastRemoveTimer = null
+ }
set({toast: null})
},
}))
diff --git a/src/index.css b/src/index.css
index 267f90c..0c33ad5 100644
--- a/src/index.css
+++ b/src/index.css
@@ -6,6 +6,17 @@ body,
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
+.input-no-spin::-webkit-outer-spin-button,
+.input-no-spin::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.input-no-spin[type='number'] {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+
.button-play {
align-items: center;
diff --git a/src/lib/request.ts b/src/lib/request.ts
index 1503958..1ecc24a 100644
--- a/src/lib/request.ts
+++ b/src/lib/request.ts
@@ -1,7 +1,9 @@
-import ky, {HTTPError, type Input, type KyInstance, type Options} from 'ky'
+import ky, {HTTPError, type AfterResponseHook, type Input, type KyInstance, type Options} from 'ky'
import {notifyError, resolveToastMessage} from '@/features/notifications'
import {useUserStore} from '@/store/user.ts'
+import type {ValidateTokenData} from '@/types/auth.type.ts'
+import {objectToFormData} from './tool'
type RequestMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
type ResponseType = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'response'
@@ -44,13 +46,97 @@ const isApiEnvelope = (value: unknown): value is ApiEnvelope =>
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/'
const REQUEST_TIMEOUT = 10_000
+const AUTH_RETRY_HEADER = 'x-auth-retried'
+const VERIFY_TOKEN_PATH = '/v1/mall/verifyToken'
let accessTokenFormatter: TokenFormatter = (token) => `Bearer ${token}`
+let refreshAuthInfoPromise: Promise | null = null
export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`)
}
+const authRefreshClient = ky.create({
+ baseUrl: API_BASE_URL,
+ timeout: REQUEST_TIMEOUT,
+ retry: 0,
+ headers: {
+ lang: 'zh',
+ },
+})
+
+const handleUnauthorizedRetry: AfterResponseHook = async ({request, response}) => {
+ if (!shouldRefreshOnUnauthorized(request, response)) {
+ return response
+ }
+
+ try {
+ const authInfo = await refreshAuthInfo()
+ const nextRequest = withRefreshedSessionId(request, authInfo.session_id)
+ return await requestClient(nextRequest)
+ } catch {
+ useUserStore.getState().clearUserInfo()
+ return response
+ }
+}
+
+async function refreshAuthInfo() {
+ if (refreshAuthInfoPromise) {
+ return refreshAuthInfoPromise
+ }
+
+ refreshAuthInfoPromise = (async () => {
+ const token = useUserStore.getState().userInfo?.token?.trim()
+ if (!token) {
+ throw new RequestError('Unauthorized')
+ }
+
+ const response = await authRefreshClient.post('v1/mall/verifyToken', {
+ body: objectToFormData({token}),
+ }).json<{
+ code: number
+ msg?: string
+ data?: ValidateTokenData
+ }>()
+
+ if (!response || (response.code !== 1 && response.code !== 200) || !response.data?.session_id) {
+ throw new RequestError(resolveToastMessage(response, 'Unauthorized'))
+ }
+
+ useUserStore.getState().setAuthInfo(response.data)
+ return response.data
+ })()
+
+ try {
+ return await refreshAuthInfoPromise
+ } finally {
+ refreshAuthInfoPromise = null
+ }
+}
+
+function shouldRefreshOnUnauthorized(request: Request, response: Response) {
+ if (response.status !== 401) {
+ return false
+ }
+
+ if (request.headers.get(AUTH_RETRY_HEADER) === '1') {
+ return false
+ }
+
+ return !request.url.includes(VERIFY_TOKEN_PATH)
+}
+
+function withRefreshedSessionId(request: Request, sessionId: string) {
+ const nextUrl = new URL(request.url)
+ if (nextUrl.searchParams.has('session_id')) {
+ nextUrl.searchParams.set('session_id', sessionId)
+ }
+
+ const nextRequest = new Request(nextUrl, request.clone())
+ nextRequest.headers.set(AUTH_RETRY_HEADER, '1')
+ return nextRequest
+}
+
const requestClient = ky.create({
baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT,
@@ -69,6 +155,9 @@ const requestClient = ky.create({
request.headers.set('Authorization', accessTokenFormatter(token))
},
],
+ afterResponse: [
+ handleUnauthorizedRetry,
+ ],
beforeError: [
async ({error}) => {
if (error instanceof HTTPError) {
diff --git a/src/mock/index.ts b/src/mock/index.ts
deleted file mode 100644
index e2b841e..0000000
--- a/src/mock/index.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import type {
- AccountTableRow,
- AddressOption,
- AddAddressForm,
- OrderRecord,
- PointsRecord,
- PointsRecordTone,
-} from '@/types'
-
-export const accountAddressRowsMock: AccountTableRow[] = [
- {
- name: 'Jia Jun',
- phone: '+86 138 0000 1288',
- address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
- code: '200120',
- action: 'Edit',
- setting: 'Default',
- },
- {
- name: 'Alicia Tan',
- phone: '+65 9123 4567',
- address: '18 Robinson Road, Singapore',
- code: '048547',
- action: 'Edit',
- setting: 'Optional',
- },
- {
- name: 'Marcus Lee',
- phone: '+60 12 778 9911',
- address: '27 Jalan Bukit Bintang, Kuala Lumpur',
- code: '55100',
- action: 'Edit',
- setting: 'Optional',
- },
-]
-
-export const initialAddressOptionsMock: AddressOption[] = [
- {
- id: 'address-shanghai',
- name: 'Jia Jun',
- phone: '+86 138 0000 1288',
- address: 'No. 88 Century Avenue, Pudong New Area, Shanghai',
- isDefault: true,
- },
- {
- id: 'address-singapore',
- name: 'Alicia Tan',
- phone: '+65 9123 4567',
- address: '18 Robinson Road, Singapore',
- },
- {
- id: 'address-kuala-lumpur',
- name: 'Marcus Lee',
- phone: '+60 12 778 9911',
- address: '27 Jalan Bukit Bintang, Kuala Lumpur',
- },
-]
-
-export const emptyAddressFormMock: AddAddressForm = {
- name: '',
- phone: '',
- region: [],
- detailedAddress: '',
- isDefault: false,
-}
-
-export const orderRecordsMock: OrderRecord[] = [
- {
- id: 'order-1',
- date: '2025-03-04',
- time: '10:20',
- category: 'Bonus',
- title: 'Daily Rebate 50',
- status: 'Issued',
- points: '-500 points',
- },
- {
- id: 'order-2',
- date: '2025-03-03',
- time: '14:00',
- category: 'Physical',
- title: 'Weekly Bonus 200',
- trackingNumber: 'SF1234567890',
- status: 'Shipped',
- points: '-1200 points',
- },
- {
- id: 'order-3',
- date: '2025-03-02',
- time: '09:15',
- category: 'Withdrawal',
- title: 'Wireless Earbuds',
- status: 'Issued',
- points: '-1000 points',
- },
- {
- id: 'order-4',
- date: '2025-03-01',
- time: '16:30',
- category: 'Bonus',
- title: 'Fitness Tracker',
- status: 'Pending',
- points: '-1800 points',
- },
- {
- id: 'order-5',
- date: '2025-02-28',
- time: '11:00',
- category: 'Physical',
- title: 'Withdraw 100',
- status: 'Rejected',
- points: '-2500 points',
- },
-]
-
-export const pointsRecordsMock: PointsRecord[] = [
- {
- id: 'points-1',
- title: 'Bonus Redemption - Daily Rewards 50',
- date: '2025-03-04',
- time: '10:20',
- amount: '-500',
- tone: 'negative',
- },
- {
- id: 'points-2',
- title: "Claim Yesterday's Protection Funds",
- date: '2025-03-04',
- time: '09:20',
- amount: '+800',
- tone: 'positive',
- },
- {
- id: 'points-3',
- title: 'Physical Item Redemption - Bluetooth Headphones',
- date: '2025-03-03',
- time: '14:00',
- amount: '-1200',
- tone: 'negative',
- },
- {
- id: 'points-4',
- title: "Claim Yesterday's Protection Funds",
- date: '2025-03-03',
- time: '09:00',
- amount: '+700',
- tone: 'positive',
- },
- {
- id: 'points-5',
- title: 'Withdraw to Platform - 100',
- date: '2025-03-02',
- time: '09:15',
- amount: '-1000',
- tone: 'negative',
- },
-]
-
-export const pointsRecordToneClassNameMock: Record = {
- positive: 'bg-[#9BFFC0] text-[#176640]',
- negative: 'bg-[#FF9BA4] text-[#7B2634]',
-}
diff --git a/src/types/index.ts b/src/types/index.ts
index 4d33f33..946d9ca 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -40,11 +40,6 @@ export type TableColumn> = {
render?: (value: T[keyof T], record: T, index: number) => ReactNode
}
-export type BorderlessTableProps> = {
- columns: TableColumn[]
- dataSource: T[]
-}
-
export type QuickNavCardProps = {
icon: LucideIcon
label: string
diff --git a/src/views/account/index.tsx b/src/views/account/index.tsx
index 2ab4b70..cf9b191 100644
--- a/src/views/account/index.tsx
+++ b/src/views/account/index.tsx
@@ -1,7 +1,6 @@
import {useState} from 'react'
import PageLayout from '@/components/layout'
-import BorderlessTable from '@/components/table'
import Modal from '@/components/modal'
import Button from '@/components/button'
import {Link} from 'react-router-dom'
@@ -10,16 +9,6 @@ import {useAddressBook} from '@/features/addressBook'
import {GoodsRedeemModal} from '@/features/goods'
import {notifySuccess} from '@/features/notifications'
import type {AddressListItem} from '@/types/address.type.ts'
-import type {TableColumn} from '@/types'
-
-type AddressTableRow = {
- id: string
- name: string
- phone: string
- address: string
- action: string
- setting: string
-}
function AccountPage() {
const addressBook = useAddressBook({autoLoad: true})
@@ -27,15 +16,6 @@ function AccountPage() {
const [editingAddress, setEditingAddress] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null)
- const rows: AddressTableRow[] = addressBook.addresses.map((item) => ({
- id: String(item.id),
- name: item.receiver_name,
- phone: item.phone,
- address: addressBook.addressOptions.find((option) => option.id === String(item.id))?.address ?? '',
- action: 'Edit',
- setting: item.default_setting === 1 ? 'Default' : 'Optional',
- }))
-
const isAddressFormValid = addressBook.isAddressFormValid
const handleOpenAddAddress = () => {
@@ -76,63 +56,6 @@ function AccountPage() {
}
}
- const columns: TableColumn[] = [
- {
- label: 'Name',
- key: 'name',
- render: (value: string) => {value}
,
- },
- {
- label: 'Phone / Mobile',
- key: 'phone',
- render: (value: string) => {value}
,
- },
- {
- label: 'Address',
- key: 'address',
- render: (value: string) => {value}
,
- },
- {
- label: 'Action',
- key: 'action',
- render: (_value: string, _record: AddressTableRow, index: number) => (
-
-
handleOpenEditAddress(addressBook.addresses[index])}
- >
-
- Edit
-
-
setDeleteTarget(addressBook.addresses[index])}
- >
-
- Delete
-
-
- ),
- },
- {
- label: 'Default Setting',
- key: 'setting',
- render: (value: string) => (
-
- {value}
-
- ),
- },
- ]
-
return (
) : !addressBook.addresses.length ? (
- No shipping address found. Add one to start redeeming physical rewards.
+ No shipping address found. Add one.
) : (
- <>
-
- {rows.map((item, index) => (
-
+
+ {addressBook.addresses.map((address) => {
+ const addressId = String(address.id)
+ const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
+ const isDefault = address.default_setting === 1
+
+ return (
+
-
{item.name}
-
{item.phone}
+
{address.receiver_name}
+
{address.phone}
- {item.setting === 'Default' ? (
+ {isDefault ? (
- {item.setting}
+ Default
- ) : item.setting}
+ ) : 'Optional'}
Address
-
{item.address}
+
{addressText}
+
+
+
handleOpenEditAddress(address)}
+ >
+
+ Edit
+
+
setDeleteTarget(address)}
+ >
+
+ Delete
+
-
handleOpenEditAddress(addressBook.addresses[index])}
- >
-
- Edit
-
-
setDeleteTarget(addressBook.addresses[index])}
- >
-
- Delete
-
- ))}
-
-
-
-
-
- >
+ )})}
+
)}
diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx
index b8b814d..ce91052 100644
--- a/src/views/home/index.tsx
+++ b/src/views/home/index.tsx
@@ -132,11 +132,11 @@ function HomePage() {
-
+
-
Claimable
+
Claimable
Points
-
Daily Claim
+
Daily Claim
Limit
Claimed: {assetsInfo?.today_claimed || 0} / {assetsInfo?.today_limit || 0}
+ className="mt-[10px] text-[13px] text-white/68">Claimed:
{assetsInfo?.today_claimed || 0} / {assetsInfo?.today_limit || 0}
+
-
+ className="liquid-glass-bg flex min-h-[100px] flex-col justify-between p-[14px] sm:p-[16px]">
+
-
Available for
- Withdrawal
+
Available for
+ Withdrawal (Cash)
{assetsInfo?.withdrawable_cash || 0} CNY
+ className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0}
+
CNY
+
@@ -266,7 +271,8 @@ function HomePage() {
>
- After converting the points to be collected into usable points, they can be redeemed or withdrawn. Are you sure to claim it?
+ After converting the points to be collected into usable points, they can be redeemed or withdrawn.
+ Are you sure to claim it?
diff --git a/src/views/record/index.tsx b/src/views/record/index.tsx
index 0b74d56..5f66c18 100644
--- a/src/views/record/index.tsx
+++ b/src/views/record/index.tsx
@@ -1,4 +1,4 @@
-import {useEffect, useState} from 'react'
+import {useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import PageLayout from '@/components/layout'
@@ -274,11 +274,11 @@ function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
function OrderCard({ record, onOpenDetails }: OrderCardProps) {
return (
-
+
{record.date} {record.time} • {record.category}
-
-
+
+
{record.title}
{record.trackingNumber ? (
@@ -295,7 +295,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
-
+
-
+
{record.title}
-
+
{record.date} {record.time}
@@ -427,10 +427,6 @@ function RecordPage() {
setSelectedOrder(null)
}
- useEffect(() => {
- setSelectedOrder(null)
- }, [tab])
-
return (
setTab('order')}
+ onClick={() => {
+ setSelectedOrder(null)
+ setTab('order')
+ }}
/>
setTab('record')}
+ onClick={() => {
+ setSelectedOrder(null)
+ setTab('record')
+ }}
/>
@@ -487,7 +489,7 @@ function RecordPage() {
}
>
{selectedOrder ? (
-
+
{[
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },