Files
dukang/apps/h5-user/src/pages/StoreListPage.tsx
T

284 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { track } from '../lib/analytics';
import TabMainHeader from '../components/TabMainHeader';
import RegionPicker from '../components/RegionPicker';
import {
DEFAULT_REGION,
FALLBACK_CITY_REGION,
formatRegion,
REGION_ALL,
type RegionSelection,
} from '../lib/region-data';
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
type OpenCity = {
code: string;
name: string;
province: string;
};
type StoreItem = {
id: string;
name: string;
province?: string;
cityName?: string;
district: string;
address: string;
coverUrl?: string | null;
status: string;
openTime?: string | null;
closeTime?: string | null;
category?: { name: string } | null;
};
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
}
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
function storeCover(store: StoreItem, index: number) {
if (store.coverUrl) return String(store.coverUrl);
const imgs = ['/images/1.png', '/images/2.png', '/images/3.png'];
return imgs[index % imgs.length];
}
function formatHours(store: StoreItem) {
if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`;
}
return '营业时间: 10:00-22:00';
}
export default function StoreListPage() {
const navigate = useNavigate();
const [cities, setCities] = useState<OpenCity[]>([]);
const [stores, setStores] = useState<StoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
const [usedFallback, setUsedFallback] = useState(false);
const [geoReady, setGeoReady] = useState(false);
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
useEffect(() => {
track('store_list_view', { pagePath: '/stores' });
}, []);
useEffect(() => {
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
}, []);
useEffect(() => {
if (filterMode !== 'auto' || geoReady) return;
resolveUserCity().then((resolved) => {
if (resolved) {
setRegion(resolved.region);
}
setGeoReady(true);
});
}, [filterMode, geoReady]);
const fetchStores = useCallback(
async (targetRegion: RegionSelection, allowFallback: boolean) => {
const code = resolveCityCode(targetRegion, cities);
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
if (
allowFallback &&
filterMode === 'auto' &&
data.length === 0 &&
!usedFallback &&
code &&
code !== FALLBACK_CITY_CODE
) {
setUsedFallback(true);
setRegion(FALLBACK_CITY_REGION);
return null;
}
return data;
},
[cities, filterMode, usedFallback],
);
useEffect(() => {
if (filterMode === 'auto' && !geoReady) return;
let cancelled = false;
setLoading(true);
fetchStores(region, true)
.then((data) => {
if (!cancelled && data) setStores(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
const filtered = useMemo(() => {
let list = stores;
if (categoryTab !== '全部') {
list = list.filter((s) => s.category?.name === categoryTab);
}
if (!cityCode) {
list = list.filter((s) => {
const province = s.province ?? '河南省';
const city = s.cityName ?? '郑州市';
if (region.province !== REGION_ALL && province !== region.province) return false;
if (region.city !== REGION_ALL && city !== region.city) return false;
if (region.district !== REGION_ALL && s.district !== region.district) return false;
return true;
});
} else if (region.district !== REGION_ALL) {
list = list.filter((s) => s.district === region.district);
}
const q = keyword.trim().toLowerCase();
if (q) {
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.address.toLowerCase().includes(q) ||
s.district.toLowerCase().includes(q),
);
}
return list;
}, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegion(region.province, region.city, region.district);
const emptyMessage =
filterMode === 'manual' && filtered.length === 0
? '未找到匹配门店'
: '暂无门店';
return (
<div className="page store-page">
<header className="store-header">
<TabMainHeader title="门店" />
<div className="store-toolbar">
<button
type="button"
className="store-location"
onClick={() => setRegionPickerOpen(true)}
>
<span className="material-symbols-outlined">location_on</span>
<span>{regionLabel}</span>
<span className="material-symbols-outlined store-location-arrow">expand_more</span>
</button>
<div className="store-search">
<span className="material-symbols-outlined store-search-icon">search</span>
<input
type="search"
placeholder="搜索门店名称或地址"
value={keyword}
onChange={(e) => {
setFilterMode('manual');
setKeyword(e.target.value);
}}
/>
</div>
</div>
<div className="store-category-bar">
<div className="store-category-tabs">
{CATEGORY_TABS.map((tab) => (
<button
key={tab}
type="button"
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
onClick={() => {
setFilterMode('manual');
setCategoryTab(tab);
}}
>
{tab}
</button>
))}
</div>
</div>
</header>
<main className="store-list">
{loading && <div className="store-empty">加载门店中...</div>}
{!loading && filtered.map((s, index) => (
<article
key={s.id}
className="store-card"
role="button"
tabIndex={0}
onClick={() => navigate(`/stores/${s.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigate(`/stores/${s.id}`);
}
}}
>
<div className="store-card-cover">
<AppImage src={storeCover(s, index)} alt={s.name} wrapperClassName="app-image--fill" />
</div>
<div className="store-card-body">
<div className="store-card-top">
<div className="store-card-title-row">
<h3 className="store-card-name">{s.name}</h3>
<span className="store-card-distance">
{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
</span>
</div>
<div className="store-card-meta">
<span className="status-open">营业中</span>
<span className="store-card-hours">{formatHours(s)}</span>
</div>
</div>
<div className="store-card-bottom">
<p className="store-card-address">
{s.district}
{s.address}
</p>
<Link
to="/redeem"
className="store-redeem-btn"
onClick={(e) => e.stopPropagation()}
>
去核销
</Link>
</div>
</div>
</article>
))}
{!loading && filtered.length === 0 && (
<div className="store-empty">{emptyMessage}</div>
)}
{!loading && filtered.length > 0 && (
<p className="store-list-end">没有更多门店了</p>
)}
</main>
<RegionPicker
open={regionPickerOpen}
value={region}
onClose={() => setRegionPickerOpen(false)}
onConfirm={(next) => {
setFilterMode('manual');
setRegion(next);
setRegionPickerOpen(false);
}}
/>
</div>
);
}