feat:项目界面补充

This commit is contained in:
JiaJun
2026-04-10 17:29:49 +08:00
parent af3ed15ba2
commit 8fcf0355b3
19 changed files with 292 additions and 407 deletions

View File

@@ -21,8 +21,6 @@ function App() {
// //
// const {token, language} = message.payload // const {token, language} = message.payload
// //
// console.log('postMessage', token, language)
//
// if (typeof token === 'string' && token.trim()) { // if (typeof token === 'string' && token.trim()) {
// sessionStorage.setItem('host_token', token) // sessionStorage.setItem('host_token', token)
// } // }

View File

@@ -1,3 +1,5 @@
import {Children} from 'react'
import { cn } from '@/lib' import { cn } from '@/lib'
import type { ModalProps } from '@/types' import type { ModalProps } from '@/types'
@@ -15,6 +17,9 @@ export function Modal({
return null return null
} }
const footerItems = Children.toArray(footer)
const hasMultipleFooterItems = footerItems.length > 1
const handleOverlayClick = () => { const handleOverlayClick = () => {
if (closeOnOverlayClick) { if (closeOnOverlayClick) {
onClose?.() onClose?.()
@@ -62,8 +67,15 @@ export function Modal({
</div> </div>
{footer ? ( {footer ? (
<div className="shrink-0 border-t border-white/8 px-[16px] py-[14px] sm:px-[20px] sm:py-[16px]"> <div className="shrink-0 border-t border-white/8 px-[16px] py-[14px] sm:px-[20px] sm:py-[16px]">
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-6"> <div
{footer} className={cn(
'items-center justify-center gap-3 sm:gap-6',
hasMultipleFooterItems
? 'flex flex-nowrap [&>*]:min-w-0 [&>*]:flex-1 sm:[&>*]:flex-none'
: 'flex',
)}
>
{footerItems}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,55 +0,0 @@
import type { BorderlessTableProps } from '@/types'
function BorderlessTable<T extends Record<string, string>>({
columns,
dataSource,
}: BorderlessTableProps<T>) {
return (
<div className="overflow-x-auto rounded-[14px] border border-white/8 bg-[#08070E]/58 p-[10px] shadow-[0_12px_32px_rgba(0,0,0,0.22)]">
<table className="w-full min-w-[860px] table-fixed border-collapse text-left text-[14px] text-white">
<thead>
<tr className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] text-white">
{columns.map((column) => (
<th
key={String(column.key)}
className="px-[16px] py-[14px] text-[12px] font-bold uppercase tracking-[0.08em] first:rounded-tl-[10px] last:rounded-tr-[10px]"
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{dataSource.map((record, index) => (
<tr
key={`${record.name}-${index}`}
className="border-b border-white/6 bg-white/4 transition-colors last:border-b-0 hover:bg-white/8"
>
{columns.map((column, columnIndex) => {
const value = record[column.key]
const isLastRow = index === dataSource.length - 1
const isFirstColumn = columnIndex === 0
const isLastColumn = columnIndex === columns.length - 1
return (
<td
key={String(column.key)}
className={`px-[16px] py-[16px] align-top text-[13px] leading-[1.55] text-white/90 ${
isLastRow && isFirstColumn ? 'rounded-bl-[10px]' : ''
} ${
isLastRow && isLastColumn ? 'rounded-br-[10px]' : ''
}`}
>
{column.render ? column.render(value, record, index) : value}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default BorderlessTable

View File

@@ -5,6 +5,8 @@ type AddressValidationResult =
| { valid: false; message: string } | { valid: false; message: string }
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult { export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
const normalizedRegion = addressForm.region.filter((value) => value.trim())
if (!addressForm.name.trim()) { if (!addressForm.name.trim()) {
return { return {
valid: false, valid: false,
@@ -19,10 +21,10 @@ export function validateAddressFormSubmission(addressForm: AddAddressForm): Addr
} }
} }
if (addressForm.region.length !== 3) { if (normalizedRegion.length === 0) {
return { return {
valid: false, valid: false,
message: 'Please select province, city and district.', message: 'Please select a region.',
} }
} }

View File

@@ -5,7 +5,6 @@ import {addressAdd, addressDelete, addressEdit, addressList} from '@/api/address
import {validateAddressFormSubmission} from '@/features/addressBook/addressValidation' import {validateAddressFormSubmission} from '@/features/addressBook/addressValidation'
import {notifyError} from '@/features/notifications' import {notifyError} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts' import {queryKeys} from '@/lib/queryKeys.ts'
import {emptyAddressFormMock} from '@/mock'
import {useUserStore} from '@/store/user.ts' import {useUserStore} from '@/store/user.ts'
import type {AddressListItem} from '@/types/address.type.ts' import type {AddressListItem} from '@/types/address.type.ts'
import type {AddAddressForm, AddressOption} from '@/types' import type {AddAddressForm, AddressOption} from '@/types'
@@ -14,6 +13,14 @@ type UseAddressBookOptions = {
autoLoad?: boolean autoLoad?: boolean
} }
const emptyAddressForm: AddAddressForm = {
name: '',
phone: '',
region: [],
detailedAddress: '',
isDefault: false,
}
export function getAddressText(item: AddressListItem) { export function getAddressText(item: AddressListItem) {
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ') const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
return [regionText, item.detail_address].filter(Boolean).join(', ') return [regionText, item.detail_address].filter(Boolean).join(', ')
@@ -42,7 +49,7 @@ export function mapAddressToForm(item: AddressListItem): AddAddressForm {
export function useAddressBook(options?: UseAddressBookOptions) { export function useAddressBook(options?: UseAddressBookOptions) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '') const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressFormMock) const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressForm)
const addressListQuery = useQuery({ const addressListQuery = useQuery({
queryKey: queryKeys.addressList(sessionId), queryKey: queryKeys.addressList(sessionId),
@@ -111,12 +118,12 @@ export function useAddressBook(options?: UseAddressBookOptions) {
}, [queryClient, sessionId]) }, [queryClient, sessionId])
const resetAddressForm = () => { const resetAddressForm = () => {
setAddressForm(emptyAddressFormMock) setAddressForm(emptyAddressForm)
saveAddressMutation.reset() saveAddressMutation.reset()
} }
const fillAddressForm = (address?: AddressListItem | null) => { const fillAddressForm = (address?: AddressListItem | null) => {
setAddressForm(address ? mapAddressToForm(address) : emptyAddressFormMock) setAddressForm(address ? mapAddressToForm(address) : emptyAddressForm)
saveAddressMutation.reset() saveAddressMutation.reset()
} }

View File

@@ -64,7 +64,7 @@ export function GoodsCategoryList({
if (!categories.length) { if (!categories.length) {
return ( return (
<div className="pb-[24px]"> <div className="pb-[24px] mt-[20px]">
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60"> <div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
{emptyText} {emptyText}
</div> </div>

View File

@@ -92,7 +92,7 @@ export function GoodsRedeemModal({
<div <div
className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]"> className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<div <div
className="flex flex-col gap-[10px] border-b border-white/10 px-[6px] pb-[16px] sm:flex-row sm:items-center sm:justify-between"> className="flex items-center justify-between gap-[10px] border-b border-white/10 px-[6px] pb-[16px]">
<div className="flex items-center gap-[8px] text-[18px] font-medium text-white"> <div className="flex items-center gap-[8px] text-[18px] font-medium text-white">
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/> <MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
<span>Address Info</span> <span>Address Info</span>
@@ -140,7 +140,7 @@ export function GoodsRedeemModal({
type="number" type="number"
onChange={(event) => onChangeAddressForm('phone', event.target.value)} onChange={(event) => onChangeAddressForm('phone', event.target.value)}
placeholder="Enter a reachable mobile number" 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"
/> />
</div> </div>
<div <div
@@ -161,7 +161,7 @@ export function GoodsRedeemModal({
<input <input
value={addressForm.detailedAddress} value={addressForm.detailedAddress}
onChange={(event) => onChangeAddressForm('detailedAddress', event.target.value)} onChange={(event) => 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" className="bg-transparent text-[14px] text-white outline-none placeholder:text-white/35"
/> />
</div> </div>

View File

@@ -1,4 +1,5 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {ChevronDown} from 'lucide-react'
type ProvinceNode = { type ProvinceNode = {
c: string c: string
@@ -32,6 +33,8 @@ type RegionDataset = {
area: AreaNode[] area: AreaNode[]
} }
const DIRECT_MUNICIPALITY_NAMES = new Set(['北京市', '上海市', '天津市', '重庆市'])
function RegionSelect({ function RegionSelect({
value, value,
placeholder, placeholder,
@@ -60,8 +63,8 @@ function RegionSelect({
</option> </option>
))} ))}
</select> </select>
<div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-[12px] text-white/38"> <div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-white/38">
v <ChevronDown className="h-[14px] w-[14px]" aria-hidden="true" />
</div> </div>
</div> </div>
) )
@@ -70,7 +73,8 @@ function RegionSelect({
export function RegionPicker({value, onChange}: RegionPickerProps) { export function RegionPicker({value, onChange}: RegionPickerProps) {
const [regionData, setRegionData] = useState<RegionDataset | null>(null) const [regionData, setRegionData] = useState<RegionDataset | null>(null)
const [loadFailed, setLoadFailed] = useState(false) const [loadFailed, setLoadFailed] = useState(false)
const [province = '', city = '', district = ''] = value const normalizedValue = value.filter(Boolean)
const province = normalizedValue[0] ?? ''
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -120,18 +124,24 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
const areaList = regionData?.area ?? [] const areaList = regionData?.area ?? []
const selectedProvince = provinceList.find((item) => item.n === province) const selectedProvince = provinceList.find((item) => item.n === province)
const selectedCity = cityList.find((item) => item.n === city && item.p === selectedProvince?.p)
const cities = selectedProvince const cities = selectedProvince
? cityList.filter((item) => item.p === selectedProvince.p) ? 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 const districts = isDirectMunicipality
? selectedProvince
? areaList.filter((item) => item.p === selectedProvince.p)
: []
: selectedCity
? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y) ? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
: [] : []
return ( return (
<div className="grid grid-cols-1 gap-[10px] sm:grid-cols-3"> <div className={`grid grid-cols-1 gap-[10px] ${isDirectMunicipality ? 'sm:grid-cols-2' : 'sm:grid-cols-3'}`}>
<RegionSelect <RegionSelect
value={province} value={province}
placeholder={loadFailed ? 'failed to load' : 'province'} placeholder={loadFailed ? 'failed to load' : 'province'}
@@ -139,6 +149,15 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
onChange={(nextProvince) => onChange(nextProvince ? [nextProvince] : [])} onChange={(nextProvince) => onChange(nextProvince ? [nextProvince] : [])}
disabled={loadFailed || !regionData} disabled={loadFailed || !regionData}
/> />
{isDirectMunicipality ? (
<RegionSelect
value={district}
placeholder="district"
options={districts}
disabled={loadFailed || !regionData || !province}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, nextDistrict] : [province])}
/>
) : (
<RegionSelect <RegionSelect
value={city} value={city}
placeholder="city" placeholder="city"
@@ -146,6 +165,8 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
disabled={loadFailed || !regionData || !province} disabled={loadFailed || !regionData || !province}
onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])} onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])}
/> />
)}
{isDirectMunicipality ? null : (
<RegionSelect <RegionSelect
value={district} value={district}
placeholder="district" placeholder="district"
@@ -153,6 +174,7 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
disabled={loadFailed || !regionData || !city} disabled={loadFailed || !regionData || !city}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])} onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
/> />
)}
</div> </div>
) )
} }

View File

@@ -33,7 +33,5 @@ export function useAssetsQuery() {
return { return {
assetsInfo: assetsQuery.data ?? null, assetsInfo: assetsQuery.data ?? null,
assetsLoading: assetsQuery.isPending,
assetsError: assetsQuery.error instanceof Error ? assetsQuery.error.message : null,
} }
} }

View File

@@ -1,4 +1,4 @@
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {goodList} from '@/api/business.ts' import {goodList} from '@/api/business.ts'
import { import {
@@ -52,7 +52,6 @@ type UseGoodsCatalogOptions = {
} }
export function useGoodsCatalog(options?: UseGoodsCatalogOptions) { export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
const queryClient = useQueryClient()
const types = options?.types?.length ? [...options.types] : HOME_GOOD_TYPE_ORDER const types = options?.types?.length ? [...options.types] : HOME_GOOD_TYPE_ORDER
const goodsCatalogQuery = useQuery({ const goodsCatalogQuery = useQuery({
queryKey: queryKeys.goodsCatalog(types), queryKey: queryKeys.goodsCatalog(types),
@@ -83,7 +82,5 @@ export function useGoodsCatalog(options?: UseGoodsCatalogOptions) {
return { return {
productCategories: goodsCatalogQuery.data ?? [], productCategories: goodsCatalogQuery.data ?? [],
loading: goodsCatalogQuery.isPending, loading: goodsCatalogQuery.isPending,
error: goodsCatalogQuery.error instanceof Error ? goodsCatalogQuery.error.message : null,
invalidateGoods: () => queryClient.invalidateQueries({queryKey: ['goods-catalog']}),
} }
} }

View File

@@ -5,16 +5,24 @@ import {useToastStore} from './store'
export function GlobalToast() { export function GlobalToast() {
const toast = useToastStore((state) => state.toast) const toast = useToastStore((state) => state.toast)
if (!toast?.visible) { if (!toast) {
return null return null
} }
const isError = toast.type === 'error' const isError = toast.type === 'error'
return ( return (
<div className="pointer-events-none fixed right-[16px] top-[16px] z-[60] max-w-[320px]">
<div <div
className={`flex items-start gap-[10px] rounded-[14px] border px-[14px] py-[12px] text-white shadow-[0_18px_40px_rgba(0,0,0,0.35)] backdrop-blur-[8px] ${ className={`pointer-events-none fixed right-[16px] top-[16px] z-[60] max-w-[320px] transition-all duration-220 ease-out ${
toast.visible
? 'translate-y-0 opacity-100'
: '-translate-y-2 opacity-0'
}`}
>
<div
className={`flex items-start gap-[10px] rounded-[14px] border px-[14px] py-[12px] text-white shadow-[0_18px_40px_rgba(0,0,0,0.35)] backdrop-blur-[8px] transition-all duration-220 ease-out ${
toast.visible ? 'scale-100' : 'scale-[0.98]'
} ${
isError isError
? 'border-[#FF9BA4]/35 bg-[#3A1318]/94' ? 'border-[#FF9BA4]/35 bg-[#3A1318]/94'
: 'border-[#9BFFC0]/35 bg-[#11311E]/94' : 'border-[#9BFFC0]/35 bg-[#11311E]/94'

View File

@@ -15,6 +15,7 @@ type ToastStore = {
} }
let toastTimer: ReturnType<typeof setTimeout> | null = null let toastTimer: ReturnType<typeof setTimeout> | null = null
let toastRemoveTimer: ReturnType<typeof setTimeout> | null = null
export function resolveToastMessage(source: unknown, fallback = '') { export function resolveToastMessage(source: unknown, fallback = '') {
if (typeof source === 'string' && source.trim()) { if (typeof source === 'string' && source.trim()) {
@@ -37,18 +38,44 @@ export const useToastStore = create<ToastStore>((set) => ({
if (toastTimer) { if (toastTimer) {
clearTimeout(toastTimer) clearTimeout(toastTimer)
} }
if (toastRemoveTimer) {
clearTimeout(toastRemoveTimer)
}
set({ set({
toast: { toast: {
message, message,
type, type,
visible: true, visible: false,
}, },
}) })
requestAnimationFrame(() => {
set((state) => state.toast
? {
toast: {
...state.toast,
visible: true,
},
}
: state)
})
toastTimer = setTimeout(() => { toastTimer = setTimeout(() => {
set({toast: null}) set((state) => state.toast
? {
toast: {
...state.toast,
visible: false,
},
}
: state)
toastTimer = null toastTimer = null
toastRemoveTimer = setTimeout(() => {
set({toast: null})
toastRemoveTimer = null
}, 220)
}, 2000) }, 2000)
}, },
clearToast: () => { clearToast: () => {
@@ -56,6 +83,10 @@ export const useToastStore = create<ToastStore>((set) => ({
clearTimeout(toastTimer) clearTimeout(toastTimer)
toastTimer = null toastTimer = null
} }
if (toastRemoveTimer) {
clearTimeout(toastRemoveTimer)
toastRemoveTimer = null
}
set({toast: null}) set({toast: null})
}, },
})) }))

View File

@@ -6,6 +6,17 @@ body,
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 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 { .button-play {
align-items: center; align-items: center;

View File

@@ -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 {notifyError, resolveToastMessage} from '@/features/notifications'
import {useUserStore} from '@/store/user.ts' 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 RequestMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
type ResponseType = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'response' 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 API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/'
const REQUEST_TIMEOUT = 10_000 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 accessTokenFormatter: TokenFormatter = (token) => `Bearer ${token}`
let refreshAuthInfoPromise: Promise<ValidateTokenData> | null = null
export const setAccessTokenFormatter = (formatter?: TokenFormatter) => { export const setAccessTokenFormatter = (formatter?: TokenFormatter) => {
accessTokenFormatter = formatter ?? ((token) => `Bearer ${token}`) 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({ const requestClient = ky.create({
baseUrl: API_BASE_URL, baseUrl: API_BASE_URL,
timeout: REQUEST_TIMEOUT, timeout: REQUEST_TIMEOUT,
@@ -69,6 +155,9 @@ const requestClient = ky.create({
request.headers.set('Authorization', accessTokenFormatter(token)) request.headers.set('Authorization', accessTokenFormatter(token))
}, },
], ],
afterResponse: [
handleUnauthorizedRetry,
],
beforeError: [ beforeError: [
async ({error}) => { async ({error}) => {
if (error instanceof HTTPError) { if (error instanceof HTTPError) {

View File

@@ -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<PointsRecordTone, string> = {
positive: 'bg-[#9BFFC0] text-[#176640]',
negative: 'bg-[#FF9BA4] text-[#7B2634]',
}

View File

@@ -40,11 +40,6 @@ export type TableColumn<T extends Record<string, string>> = {
render?: (value: T[keyof T], record: T, index: number) => ReactNode render?: (value: T[keyof T], record: T, index: number) => ReactNode
} }
export type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
export type QuickNavCardProps = { export type QuickNavCardProps = {
icon: LucideIcon icon: LucideIcon
label: string label: string

View File

@@ -1,7 +1,6 @@
import {useState} from 'react' import {useState} from 'react'
import PageLayout from '@/components/layout' import PageLayout from '@/components/layout'
import BorderlessTable from '@/components/table'
import Modal from '@/components/modal' import Modal from '@/components/modal'
import Button from '@/components/button' import Button from '@/components/button'
import {Link} from 'react-router-dom' import {Link} from 'react-router-dom'
@@ -10,16 +9,6 @@ import {useAddressBook} from '@/features/addressBook'
import {GoodsRedeemModal} from '@/features/goods' import {GoodsRedeemModal} from '@/features/goods'
import {notifySuccess} from '@/features/notifications' import {notifySuccess} from '@/features/notifications'
import type {AddressListItem} from '@/types/address.type.ts' 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() { function AccountPage() {
const addressBook = useAddressBook({autoLoad: true}) const addressBook = useAddressBook({autoLoad: true})
@@ -27,15 +16,6 @@ function AccountPage() {
const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null) const [editingAddress, setEditingAddress] = useState<AddressListItem | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AddressListItem | null>(null) const [deleteTarget, setDeleteTarget] = useState<AddressListItem | null>(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 isAddressFormValid = addressBook.isAddressFormValid
const handleOpenAddAddress = () => { const handleOpenAddAddress = () => {
@@ -76,63 +56,6 @@ function AccountPage() {
} }
} }
const columns: TableColumn<AddressTableRow>[] = [
{
label: 'Name',
key: 'name',
render: (value: string) => <div className="font-medium text-white">{value}</div>,
},
{
label: 'Phone / Mobile',
key: 'phone',
render: (value: string) => <div className="text-white/72">{value}</div>,
},
{
label: 'Address',
key: 'address',
render: (value: string) => <div className="max-w-[280px] text-white/72">{value}</div>,
},
{
label: 'Action',
key: 'action',
render: (_value: string, _record: AddressTableRow, index: number) => (
<div className="flex items-center gap-[8px]">
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => handleOpenEditAddress(addressBook.addresses[index])}
>
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
Edit
</button>
<button
type="button"
className="inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => setDeleteTarget(addressBook.addresses[index])}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete
</button>
</div>
),
},
{
label: 'Default Setting',
key: 'setting',
render: (value: string) => (
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
value === 'Default'
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{value}
</div>
),
},
]
return ( return (
<PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 pb-8 sm:px-6 lg:px-8"> <PageLayout contentClassName="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 pb-8 sm:px-6 lg:px-8">
<Link <Link
@@ -173,61 +96,62 @@ function AccountPage() {
</div> </div>
) : !addressBook.addresses.length ? ( ) : !addressBook.addresses.length ? (
<div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60"> <div className="liquid-glass-bg px-[16px] py-[18px] text-[14px] text-white/60">
No shipping address found. Add one to start redeeming physical rewards. No shipping address found. Add one.
</div> </div>
) : ( ) : (
<> <div className="grid grid-cols-1 gap-[12px]">
<div className="space-y-[12px] lg:hidden"> {addressBook.addresses.map((address) => {
{rows.map((item, index) => ( const addressId = String(address.id)
<div key={item.id} className="liquid-glass-bg p-[14px]"> const addressText = addressBook.addressOptions.find((option) => option.id === addressId)?.address ?? ''
const isDefault = address.default_setting === 1
return (
<div key={addressId} className="liquid-glass-bg p-[14px]">
<div className="flex items-start justify-between gap-[12px]"> <div className="flex items-start justify-between gap-[12px]">
<div> <div>
<div className="text-[16px] font-semibold text-white">{item.name}</div> <div className="text-[16px] font-semibold text-white">{address.receiver_name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{item.phone}</div> <div className="mt-[4px] text-[13px] text-white/62">{address.phone}</div>
</div> </div>
<div <div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${ className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
item.setting === 'Default' isDefault
? 'bg-[#FA6A00]/14 text-[#FFB36D]' ? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62' : 'bg-white/6 text-white/62'
}`} }`}
> >
{item.setting === 'Default' ? ( {isDefault ? (
<span className="inline-flex items-center gap-[5px]"> <span className="inline-flex items-center gap-[5px]">
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" /> <BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
{item.setting} Default
</span> </span>
) : item.setting} ) : 'Optional'}
</div> </div>
</div> </div>
<div className="mt-[12px] rounded-[10px] bg-black/12 p-[12px]"> <div className="mt-[12px] rounded-[10px] bg-black/12 p-[12px]">
<div className="text-[12px] uppercase tracking-[0.08em] text-white/44">Address</div> <div className="text-[12px] uppercase tracking-[0.08em] text-white/44">Address</div>
<div className="mt-[6px] text-[13px] leading-[1.6] text-white/78">{item.address}</div> <div className="mt-[6px] text-[13px] leading-[1.6] text-white/78">{addressText}</div>
</div> </div>
<div className="mt-[12px] flex justify-end gap-[10px]">
<button <button
type="button" type="button"
className="mt-[12px] inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]" className="inline-flex items-center gap-[6px] rounded-full bg-white/6 px-[10px] py-[6px] text-[12px] text-white/82 transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => handleOpenEditAddress(addressBook.addresses[index])} onClick={() => handleOpenEditAddress(address)}
> >
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" /> <PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
Edit Edit
</button> </button>
<button <button
type="button" type="button"
className="mt-[10px] inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]" className="inline-flex items-center gap-[6px] rounded-full bg-[#4B1818]/70 px-[10px] py-[6px] text-[12px] text-[#FFB1B1] transition-colors hover:bg-[#612121]/80 focus-visible:ring-2 focus-visible:ring-[#FE9F00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070E]"
onClick={() => setDeleteTarget(addressBook.addresses[index])} onClick={() => setDeleteTarget(address)}
> >
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" /> <Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete Delete
</button> </button>
</div> </div>
))}
</div> </div>
)})}
<div className="hidden lg:block">
<BorderlessTable columns={columns} dataSource={rows} />
</div> </div>
</>
)} )}
</div> </div>

View File

@@ -132,11 +132,11 @@ function HomePage() {
<div className="mt-[4px]"> <div className="mt-[4px]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch"> <div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:w-[544px] lg:shrink-0"> <div className="grid grid-cols-2 gap-3 lg:w-[544px] lg:shrink-0">
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[14px]"> <div className="liquid-glass-bg flex min-h-[167px] flex-col justify-between p-[14px]">
<div className="flex items-start justify-between gap-[12px]"> <div className="flex items-start justify-between gap-[12px]">
<div> <div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Claimable <div className="text-[13px] tracking-[0.16em] text-white/58">Claimable
Points Points
</div> </div>
<div <div
@@ -154,7 +154,7 @@ function HomePage() {
<div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[14px]"> <div className="liquid-glass-bg flex min-h-[167px] flex-col justify-around p-[14px]">
<div className="flex items-start justify-between gap-[12px]"> <div className="flex items-start justify-between gap-[12px]">
<div> <div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Daily Claim <div className="text-[13px] tracking-[0.16em] text-white/58">Daily Claim
Limit Limit
</div> </div>
<div <div
@@ -178,20 +178,25 @@ function HomePage() {
></div> ></div>
</div> </div>
<div <div
className="mt-[10px] text-[13px] text-white/68">Claimed: <span className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}</div> className="mt-[10px] text-[13px] text-white/68">Claimed: <span
className={'text-[#FE9C00]'}>{assetsInfo?.today_claimed || 0}</span> / {assetsInfo?.today_limit || 0}
</div>
</div> </div>
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-3"> <div className="flex min-w-0 flex-1 flex-col gap-3">
<div <div
className="liquid-glass-bg flex min-h-[109px] flex-col justify-between p-[14px] sm:p-[16px]"> className="liquid-glass-bg flex min-h-[100px] flex-col justify-between p-[14px] sm:p-[16px]">
<div className="flex items-start justify-between gap-[12px]"> <div className="flex items-center justify-between gap-[12px]">
<div> <div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Available for <div className="text-[13px] tracking-[0.16em] text-white/58">Available for
Withdrawal Withdrawal (Cash)
</div> </div>
<div <div
className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0} CNY</div> className="mt-[10px] text-[32px] font-semibold leading-none text-white">{assetsInfo?.withdrawable_cash || 0}
<span
className="text-[13px] tracking-[0.16em] text-white/58 ml-[10px]">CNY</span>
</div>
</div> </div>
<div <div
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]"> className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
@@ -266,7 +271,8 @@ function HomePage() {
> >
<div <div
className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]"> className="rounded-[12px] bg-[#1C1818]/82 px-[14px] py-[18px] text-[17px] leading-[1.65] text-white/92 shadow-[0_10px_30px_rgba(0,0,0,0.2)]">
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?
</div> </div>
</Modal> </Modal>
</PageLayout> </PageLayout>

View File

@@ -1,4 +1,4 @@
import {useEffect, useState} from 'react' import {useState} from 'react'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import PageLayout from '@/components/layout' import PageLayout from '@/components/layout'
@@ -274,11 +274,11 @@ function TabButton({ active, label, icon: Icon, onClick }: TabButtonProps) {
function OrderCard({ record, onOpenDetails }: OrderCardProps) { function OrderCard({ record, onOpenDetails }: OrderCardProps) {
return ( return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]"> <div className="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-[13px] text-white sm:text-[14px]"> <div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[14px] text-white">
{record.date} {record.time} {record.category} {record.date} {record.time} {record.category}
</div> </div>
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[14px] px-[12px] py-[14px] sm:flex-row sm:items-center sm:justify-between"> <div className="liquid-glass-bg !rounded-t-none flex items-center justify-between gap-[14px] px-[12px] py-[14px]">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 pr-[16px]">
<div className="text-[16px] font-medium text-white">{record.title}</div> <div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? ( {record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45"> <div className="mt-[4px] text-[13px] text-white/45">
@@ -295,7 +295,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
</button> </button>
</div> </div>
<div className="flex shrink-0 items-center justify-between gap-[10px] sm:flex-col sm:items-end"> <div className="flex shrink-0 flex-col items-end gap-[10px]">
<div <div
className={cn( className={cn(
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none', 'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
@@ -314,10 +314,10 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
function PointsCard({ record }: PointsCardProps) { function PointsCard({ record }: PointsCardProps) {
return ( return (
<div className="overflow-hidden rounded-[12px] shadow-[0_10px_30px_rgba(0,0,0,0.24)]"> <div className="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 sm:text-[15px]"> <div className="bg-linear-to-r from-[#F96C02] to-[#FE9F00] px-[12px] py-[9px] text-[15px] text-white">
{record.title} {record.title}
</div> </div>
<div className="liquid-glass-bg !rounded-t-none flex flex-col gap-[10px] px-[12px] py-[18px] sm:flex-row sm:items-center sm:justify-between sm:py-[22px]"> <div className="liquid-glass-bg !rounded-t-none flex items-center justify-between px-[12px] py-[22px]">
<div className="text-[13px] text-white/40"> <div className="text-[13px] text-white/40">
{record.date} &nbsp; {record.time} {record.date} &nbsp; {record.time}
</div> </div>
@@ -427,10 +427,6 @@ function RecordPage() {
setSelectedOrder(null) setSelectedOrder(null)
} }
useEffect(() => {
setSelectedOrder(null)
}, [tab])
return ( 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"> <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 <Link
@@ -452,13 +448,19 @@ function RecordPage() {
active={tab === 'order'} active={tab === 'order'}
icon={PackageSearch} icon={PackageSearch}
label="My Orders" label="My Orders"
onClick={() => setTab('order')} onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/> />
<TabButton <TabButton
active={tab === 'record'} active={tab === 'record'}
icon={Coins} icon={Coins}
label="Points Record" label="Points Record"
onClick={() => setTab('record')} onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/> />
</div> </div>
</div> </div>
@@ -487,7 +489,7 @@ function RecordPage() {
} }
> >
{selectedOrder ? ( {selectedOrder ? (
<div className="rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]"> <div className="mt-[10px] rounded-[10px] bg-[#1C1818]/78 px-[12px] py-[6px]">
{[ {[
{ label: 'Order Number', value: selectedOrder.orderNumber ?? '--' }, { label: 'Order Number', value: selectedOrder.orderNumber ?? '--' },
{ label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` }, { label: 'Order Time', value: `${selectedOrder.date} ${selectedOrder.time}` },