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
//
// console.log('postMessage', token, language)
//
// if (typeof token === 'string' && token.trim()) {
// sessionStorage.setItem('host_token', token)
// }

View File

@@ -1,3 +1,5 @@
import {Children} from 'react'
import { cn } from '@/lib'
import type { ModalProps } from '@/types'
@@ -15,6 +17,9 @@ export function Modal({
return null
}
const footerItems = Children.toArray(footer)
const hasMultipleFooterItems = footerItems.length > 1
const handleOverlayClick = () => {
if (closeOnOverlayClick) {
onClose?.()
@@ -62,8 +67,15 @@ export function Modal({
</div>
{footer ? (
<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">
{footer}
<div
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>
) : 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 }
export function validateAddressFormSubmission(addressForm: AddAddressForm): AddressValidationResult {
const normalizedRegion = addressForm.region.filter((value) => value.trim())
if (!addressForm.name.trim()) {
return {
valid: false,
@@ -19,10 +21,10 @@ export function validateAddressFormSubmission(addressForm: AddAddressForm): Addr
}
}
if (addressForm.region.length !== 3) {
if (normalizedRegion.length === 0) {
return {
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 {notifyError} from '@/features/notifications'
import {queryKeys} from '@/lib/queryKeys.ts'
import {emptyAddressFormMock} from '@/mock'
import {useUserStore} from '@/store/user.ts'
import type {AddressListItem} from '@/types/address.type.ts'
import type {AddAddressForm, AddressOption} from '@/types'
@@ -14,6 +13,14 @@ type UseAddressBookOptions = {
autoLoad?: boolean
}
const emptyAddressForm: AddAddressForm = {
name: '',
phone: '',
region: [],
detailedAddress: '',
isDefault: false,
}
export function getAddressText(item: AddressListItem) {
const regionText = item.region_text || item.region.map((part) => part.trim()).join(', ')
return [regionText, item.detail_address].filter(Boolean).join(', ')
@@ -42,7 +49,7 @@ export function mapAddressToForm(item: AddressListItem): AddAddressForm {
export function useAddressBook(options?: UseAddressBookOptions) {
const queryClient = useQueryClient()
const sessionId = useUserStore((state) => state.authInfo?.session_id ?? '')
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressFormMock)
const [addressForm, setAddressForm] = useState<AddAddressForm>(emptyAddressForm)
const addressListQuery = useQuery({
queryKey: queryKeys.addressList(sessionId),
@@ -111,12 +118,12 @@ export function useAddressBook(options?: UseAddressBookOptions) {
}, [queryClient, sessionId])
const resetAddressForm = () => {
setAddressForm(emptyAddressFormMock)
setAddressForm(emptyAddressForm)
saveAddressMutation.reset()
}
const fillAddressForm = (address?: AddressListItem | null) => {
setAddressForm(address ? mapAddressToForm(address) : emptyAddressFormMock)
setAddressForm(address ? mapAddressToForm(address) : emptyAddressForm)
saveAddressMutation.reset()
}

View File

@@ -64,7 +64,7 @@ export function GoodsCategoryList({
if (!categories.length) {
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">
{emptyText}
</div>

View File

@@ -92,7 +92,7 @@ export function GoodsRedeemModal({
<div
className="rounded-[12px] bg-[#171313]/88 px-[14px] py-[10px] shadow-[0_10px_30px_rgba(0,0,0,0.25)]">
<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">
<MapPinHouse className="h-[18px] w-[18px] text-[#FE9F00]" aria-hidden="true"/>
<span>Address Info</span>
@@ -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"
/>
</div>
<div
@@ -161,7 +161,7 @@ export function GoodsRedeemModal({
<input
value={addressForm.detailedAddress}
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"
/>
</div>

View File

@@ -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({
</option>
))}
</select>
<div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-[12px] text-white/38">
v
<div className="pointer-events-none absolute right-[12px] top-1/2 -translate-y-1/2 text-white/38">
<ChevronDown className="h-[14px] w-[14px]" aria-hidden="true" />
</div>
</div>
)
@@ -70,7 +73,8 @@ function RegionSelect({
export function RegionPicker({value, onChange}: RegionPickerProps) {
const [regionData, setRegionData] = useState<RegionDataset | null>(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
const districts = isDirectMunicipality
? selectedProvince
? areaList.filter((item) => item.p === selectedProvince.p)
: []
: selectedCity
? areaList.filter((item) => item.p === selectedCity.p && item.y === selectedCity.y)
: []
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
value={province}
placeholder={loadFailed ? 'failed to load' : 'province'}
@@ -139,6 +149,15 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
onChange={(nextProvince) => onChange(nextProvince ? [nextProvince] : [])}
disabled={loadFailed || !regionData}
/>
{isDirectMunicipality ? (
<RegionSelect
value={district}
placeholder="district"
options={districts}
disabled={loadFailed || !regionData || !province}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, nextDistrict] : [province])}
/>
) : (
<RegionSelect
value={city}
placeholder="city"
@@ -146,6 +165,8 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
disabled={loadFailed || !regionData || !province}
onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])}
/>
)}
{isDirectMunicipality ? null : (
<RegionSelect
value={district}
placeholder="district"
@@ -153,6 +174,7 @@ export function RegionPicker({value, onChange}: RegionPickerProps) {
disabled={loadFailed || !regionData || !city}
onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
/>
)}
</div>
)
}

View File

@@ -33,7 +33,5 @@ export function useAssetsQuery() {
return {
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 {
@@ -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']}),
}
}

View File

@@ -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 (
<div className="pointer-events-none fixed right-[16px] top-[16px] z-[60] max-w-[320px]">
<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
? 'border-[#FF9BA4]/35 bg-[#3A1318]/94'
: 'border-[#9BFFC0]/35 bg-[#11311E]/94'

View File

@@ -15,6 +15,7 @@ type ToastStore = {
}
let toastTimer: ReturnType<typeof setTimeout> | null = null
let toastRemoveTimer: ReturnType<typeof setTimeout> | null = null
export function resolveToastMessage(source: unknown, fallback = '') {
if (typeof source === 'string' && source.trim()) {
@@ -37,18 +38,44 @@ export const useToastStore = create<ToastStore>((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<ToastStore>((set) => ({
clearTimeout(toastTimer)
toastTimer = null
}
if (toastRemoveTimer) {
clearTimeout(toastRemoveTimer)
toastRemoveTimer = 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;
}
.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;

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 {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<ValidateTokenData> | 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) {

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
}
export type BorderlessTableProps<T extends Record<string, string>> = {
columns: TableColumn<T>[]
dataSource: T[]
}
export type QuickNavCardProps = {
icon: LucideIcon
label: string

View File

@@ -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<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 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 (
<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
@@ -173,61 +96,62 @@ function AccountPage() {
</div>
) : !addressBook.addresses.length ? (
<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 className="space-y-[12px] lg:hidden">
{rows.map((item, index) => (
<div key={item.id} className="liquid-glass-bg p-[14px]">
<div className="grid grid-cols-1 gap-[12px]">
{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 (
<div key={addressId} className="liquid-glass-bg p-[14px]">
<div className="flex items-start justify-between gap-[12px]">
<div>
<div className="text-[16px] font-semibold text-white">{item.name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{item.phone}</div>
<div className="text-[16px] font-semibold text-white">{address.receiver_name}</div>
<div className="mt-[4px] text-[13px] text-white/62">{address.phone}</div>
</div>
<div
className={`inline-flex rounded-full px-[10px] py-[5px] text-[12px] ${
item.setting === 'Default'
isDefault
? 'bg-[#FA6A00]/14 text-[#FFB36D]'
: 'bg-white/6 text-white/62'
}`}
>
{item.setting === 'Default' ? (
{isDefault ? (
<span className="inline-flex items-center gap-[5px]">
<BadgeCheck className="h-[12px] w-[12px]" aria-hidden="true" />
{item.setting}
Default
</span>
) : item.setting}
) : 'Optional'}
</div>
</div>
<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="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 className="mt-[12px] flex justify-end gap-[10px]">
<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]"
onClick={() => handleOpenEditAddress(addressBook.addresses[index])}
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(address)}
>
<PencilLine className="h-[12px] w-[12px]" aria-hidden="true" />
Edit
</button>
<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]"
onClick={() => setDeleteTarget(addressBook.addresses[index])}
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(address)}
>
<Trash2 className="h-[12px] w-[12px]" aria-hidden="true" />
Delete
</button>
</div>
))}
</div>
<div className="hidden lg:block">
<BorderlessTable columns={columns} dataSource={rows} />
)})}
</div>
</>
)}
</div>

View File

@@ -132,11 +132,11 @@ function HomePage() {
<div className="mt-[4px]">
<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="flex items-start justify-between gap-[12px]">
<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
</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="flex items-start justify-between gap-[12px]">
<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
</div>
<div
@@ -178,20 +178,25 @@ function HomePage() {
></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 className="flex min-w-0 flex-1 flex-col gap-3">
<div
className="liquid-glass-bg flex min-h-[109px] flex-col justify-between p-[14px] sm:p-[16px]">
<div className="flex items-start justify-between gap-[12px]">
className="liquid-glass-bg flex min-h-[100px] flex-col justify-between p-[14px] sm:p-[16px]">
<div className="flex items-center justify-between gap-[12px]">
<div>
<div className="text-[13px] uppercase tracking-[0.16em] text-white/58">Available for
Withdrawal
<div className="text-[13px] tracking-[0.16em] text-white/58">Available for
Withdrawal (Cash)
</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
className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FA6A00]/16 text-[#FE9F00]">
@@ -266,7 +271,8 @@ function HomePage() {
>
<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)]">
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>
</Modal>
</PageLayout>

View File

@@ -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 (
<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}
</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="min-w-0 flex-1">
<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 pr-[16px]">
<div className="text-[16px] font-medium text-white">{record.title}</div>
{record.trackingNumber ? (
<div className="mt-[4px] text-[13px] text-white/45">
@@ -295,7 +295,7 @@ function OrderCard({ record, onOpenDetails }: OrderCardProps) {
</button>
</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
className={cn(
'rounded-[6px] px-[8px] py-[3px] text-[11px] leading-none',
@@ -314,10 +314,10 @@ 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="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}
</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">
{record.date} &nbsp; {record.time}
</div>
@@ -427,10 +427,6 @@ function RecordPage() {
setSelectedOrder(null)
}
useEffect(() => {
setSelectedOrder(null)
}, [tab])
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
@@ -452,13 +448,19 @@ function RecordPage() {
active={tab === 'order'}
icon={PackageSearch}
label="My Orders"
onClick={() => setTab('order')}
onClick={() => {
setSelectedOrder(null)
setTab('order')
}}
/>
<TabButton
active={tab === 'record'}
icon={Coins}
label="Points Record"
onClick={() => setTab('record')}
onClick={() => {
setSelectedOrder(null)
setTab('record')
}}
/>
</div>
</div>
@@ -487,7 +489,7 @@ function RecordPage() {
}
>
{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 Time', value: `${selectedOrder.date} ${selectedOrder.time}` },