284 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|