92cf51ba3d
CI / verify (pull_request) Has been cancelled
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close. Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
9.5 KiB
TypeScript
289 lines
9.5 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,
|
||
formatRegionCity,
|
||
REGION_ALL,
|
||
toCityLevelRegion,
|
||
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;
|
||
openTime2?: string | null;
|
||
closeTime2?: string | null;
|
||
avgPrice?: number | 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) {
|
||
const parts: string[] = [];
|
||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 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>(() => toCityLevelRegion(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(toCityLevelRegion(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(toCityLevelRegion(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;
|
||
return true;
|
||
});
|
||
}
|
||
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 = formatRegionCity(region.province, region.city);
|
||
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>
|
||
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||
<span className="store-card-hours">人均¥{Number(s.avgPrice).toFixed(0)}</span>
|
||
) : null}
|
||
</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}
|
||
levels={2}
|
||
onClose={() => setRegionPickerOpen(false)}
|
||
onConfirm={(next) => {
|
||
setFilterMode('manual');
|
||
setRegion(toCityLevelRegion(next));
|
||
setRegionPickerOpen(false);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|