refactor(goods): 移除地区选择器组件并优化首页界面
This commit is contained in:
@@ -1,180 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ChevronDown} from 'lucide-react'
|
||||
|
||||
type ProvinceNode = {
|
||||
c: string
|
||||
n: string
|
||||
p: string
|
||||
}
|
||||
|
||||
type CityNode = {
|
||||
c: string
|
||||
n: string
|
||||
p: string
|
||||
y: string
|
||||
}
|
||||
|
||||
type AreaNode = {
|
||||
c: string
|
||||
n: string
|
||||
p: string
|
||||
y: string
|
||||
a: string
|
||||
}
|
||||
|
||||
type RegionPickerProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type RegionDataset = {
|
||||
province: ProvinceNode[]
|
||||
city: CityNode[]
|
||||
area: AreaNode[]
|
||||
}
|
||||
|
||||
const DIRECT_MUNICIPALITY_NAMES = new Set(['北京市', '上海市', '天津市', '重庆市'])
|
||||
|
||||
function RegionSelect({
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string
|
||||
placeholder: string
|
||||
options: Array<{c: string; n: string}>
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-[42px] w-full appearance-none rounded-[10px] border border-white/10 bg-[#0E0B12] px-[12px] pr-[36px] text-[14px] text-white outline-none transition-colors focus:border-[#FA6A00] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.c} value={option.n}>
|
||||
{option.n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function RegionPicker({value, onChange}: RegionPickerProps) {
|
||||
const [regionData, setRegionData] = useState<RegionDataset | null>(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const normalizedValue = value.filter(Boolean)
|
||||
const province = normalizedValue[0] ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadRegionData = async () => {
|
||||
try {
|
||||
const [provinceResponse, cityResponse, areaResponse] = await Promise.all([
|
||||
fetch(`${import.meta.env.BASE_URL}china-data/province.min.json`),
|
||||
fetch(`${import.meta.env.BASE_URL}china-data/city.min.json`),
|
||||
fetch(`${import.meta.env.BASE_URL}china-data/area.min.json`),
|
||||
])
|
||||
|
||||
if (!provinceResponse.ok || !cityResponse.ok || !areaResponse.ok) {
|
||||
throw new Error('Failed to load region data')
|
||||
}
|
||||
|
||||
const [provinceList, cityList, areaList] = await Promise.all([
|
||||
provinceResponse.json() as Promise<ProvinceNode[]>,
|
||||
cityResponse.json() as Promise<CityNode[]>,
|
||||
areaResponse.json() as Promise<AreaNode[]>,
|
||||
])
|
||||
|
||||
if (!cancelled) {
|
||||
setRegionData({
|
||||
province: provinceList,
|
||||
city: cityList,
|
||||
area: areaList,
|
||||
})
|
||||
setLoadFailed(false)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadFailed(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRegionData()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const provinceList = regionData?.province ?? []
|
||||
const cityList = regionData?.city ?? []
|
||||
const areaList = regionData?.area ?? []
|
||||
|
||||
const selectedProvince = provinceList.find((item) => item.n === province)
|
||||
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 = 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] ${isDirectMunicipality ? 'sm:grid-cols-2' : 'sm:grid-cols-3'}`}>
|
||||
<RegionSelect
|
||||
value={province}
|
||||
placeholder={loadFailed ? 'failed to load' : 'province'}
|
||||
options={provinceList}
|
||||
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"
|
||||
options={cities}
|
||||
disabled={loadFailed || !regionData || !province}
|
||||
onChange={(nextCity) => onChange(nextCity ? [province, nextCity] : [province])}
|
||||
/>
|
||||
)}
|
||||
{isDirectMunicipality ? null : (
|
||||
<RegionSelect
|
||||
value={district}
|
||||
placeholder="district"
|
||||
options={districts}
|
||||
disabled={loadFailed || !regionData || !city}
|
||||
onChange={(nextDistrict) => onChange(nextDistrict ? [province, city, nextDistrict] : [province, city])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from './GoodsCategoryList'
|
||||
export * from './GoodsRedeemModal'
|
||||
export * from './RegionPicker'
|
||||
export * from './useAssetsRefresh'
|
||||
export * from './useAssetsQuery'
|
||||
export * from './useGoodsCatalog'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Coins,
|
||||
Gauge,
|
||||
History,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
@@ -86,7 +87,6 @@ function HomePage() {
|
||||
const {assetsInfo} = useAssetsQuery()
|
||||
const claimProgress = getProgressPercent(assetsInfo?.today_claimed, assetsInfo?.today_limit)
|
||||
const isClaimAvailable = (assetsInfo?.locked_points ?? 0) > 0
|
||||
const isEnglish = normalizeLanguage(language) === 'en'
|
||||
const previewCategories: ProductCategory[] = productCategories.map((category) => ({
|
||||
...category,
|
||||
items: category.items.slice(0, 4),
|
||||
@@ -221,16 +221,12 @@ function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-[minmax(0,7fr)_minmax(0,3fr)] gap-3">
|
||||
<div
|
||||
className="home-withdraw-card pc-hover-float h-full min-h-[120px] py-[14px] sm:py-[18px]">
|
||||
<div className="flex h-full w-full flex-col justify-between gap-[18px]">
|
||||
<div
|
||||
className={`text-center text-white/68 ${
|
||||
isEnglish
|
||||
? 'text-[10px] tracking-[0.01em] sm:text-[13px] sm:tracking-[0.06em]'
|
||||
: 'text-[11px] tracking-[0.04em] sm:text-[14px] sm:tracking-[0.16em]'
|
||||
}`}>{t('home.availableForWithdrawal')}</div>
|
||||
className="text-center text-[11px] font-medium tracking-[0.06em] text-white/88 drop-shadow-[0_1px_10px_rgba(0,0,0,0.28)] sm:text-[14px] sm:tracking-[0.16em]">{t('home.availableForWithdrawal')}</div>
|
||||
<div className="flex flex-1 items-center justify-center text-center">
|
||||
<div
|
||||
className="min-w-0 break-all text-[clamp(1.6rem,5vw,2.6rem)] font-semibold leading-none tracking-[-0.02em] text-white">
|
||||
@@ -243,13 +239,18 @@ function HomePage() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="home-withdraw-card pc-hover-float h-full min-h-[120px] px-[12px] py-[14px] sm:px-[16px] sm:py-[18px]">
|
||||
className="home-stat-card home-stat-card-claim pc-hover-float h-full min-h-[120px] px-[12px] py-[14px] sm:px-[16px] sm:py-[18px]">
|
||||
<div className="flex h-full w-full flex-col justify-between gap-[18px]">
|
||||
<div
|
||||
className="text-center text-[12px] tracking-[0.08em] text-white/68 sm:text-[14px] sm:tracking-[0.16em]">{t('home.availablePoints')}</div>
|
||||
<div className="flex items-start justify-between gap-[10px]">
|
||||
<div
|
||||
className="text-[11px] font-medium tracking-[0.06em] text-white/82 sm:text-[13px] sm:tracking-[0.14em]">{t('home.availablePoints')}</div>
|
||||
<div className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[10px] bg-[#d58a2d]/18 text-[#f5a322] sm:h-[34px] sm:w-[34px] sm:rounded-[12px]">
|
||||
<Sparkles className="h-[14px] w-[14px] sm:h-[16px] sm:w-[16px]" aria-hidden="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-center">
|
||||
<div
|
||||
className="min-w-0 break-all text-[clamp(1.6rem,5vw,2.6rem)] font-semibold leading-none tracking-[-0.02em] text-white">
|
||||
className="min-w-0 break-all text-[clamp(1.2rem,4vw,1.9rem)] font-semibold leading-none tracking-[-0.02em] text-white">
|
||||
{assetsInfo?.available_points || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user